fix(team-runtime): harden refresh flows and reduce ui churn

This commit is contained in:
iliya 2026-04-09 16:34:55 +03:00
parent 535178a076
commit 17bd573ce3
52 changed files with 4495 additions and 439 deletions

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,8 @@
},
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
"dev": "node ./scripts/dev-with-runtime.mjs",
"dev:ui": "electron-vite dev",
"dev:kill": "node bin/kill-dev.js",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "electron-vite build",

View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
function runOrExit(cmd, args, options = {}) {
const result = spawnSync(cmd, args, {
stdio: 'inherit',
...options,
})
if (result.error) {
console.error(`Failed to run ${cmd}: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function readPackageManagerCommand(repoRoot) {
const packageJsonPath = path.join(repoRoot, 'package.json')
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8')
const packageJson = JSON.parse(rawPackageJson)
const rawPackageManager = packageJson.packageManager
if (typeof rawPackageManager !== 'string' || rawPackageManager.trim().length === 0) {
return 'pnpm'
}
const [packageManagerName] = rawPackageManager.trim().split('@', 1)
if (!packageManagerName) {
return 'pnpm'
}
return packageManagerName
}
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const uiRepoRoot = path.resolve(scriptDir, '..')
// Keep the dev runtime target explicit. This workspace can contain multiple
// sibling repos with the same package name, so auto-discovery is ambiguous and
// can silently point the UI at the wrong runtime after branch switches.
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim()
if (!runtimeRepoRoot) {
console.error(
'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' +
'Point it at the runtime repo root you want the UI to use in dev.',
)
process.exit(1)
}
const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json')
if (!fs.existsSync(runtimePackageJsonPath)) {
console.error(
`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`,
)
process.exit(1)
}
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot)
if (process.argv.includes('--print-runtime-path')) {
process.stdout.write(`${runtimeRepoRoot}\n`)
process.exit(0)
}
// Respect the runtime repo's own package manager. The UI repo uses pnpm, but
// the runtime may legitimately be a Bun workspace, and forcing pnpm there can
// fail before the build even starts.
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot })
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev')
runOrExit('pnpm', ['run', 'dev:ui'], {
cwd: uiRepoRoot,
env: {
...process.env,
CLAUDE_CLI_PATH: runtimeCliPath,
},
})

View file

@ -87,6 +87,10 @@ import {
} from './services/team/TeamControlApiState';
import { TeamInboxReader } from './services/team/TeamInboxReader';
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
import {
createTeamReconcileDrainScheduler,
type TeamReconcileTrigger,
} from './services/team/TeamReconcileDrainScheduler';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
@ -510,6 +514,27 @@ function wireFileWatcherEvents(context: ServiceContext): void {
context.fileWatcher.on('todo-change', todoChangeHandler);
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
const reconcileScheduler = teamDataService
? createTeamReconcileDrainScheduler({
run: async (teamName: string, trigger: TeamReconcileTrigger) => {
try {
await teamDataService.reconcileTeamArtifacts(teamName, trigger);
} catch (e) {
if (trigger.source === 'task') {
logger.warn(
`[FileWatcher] task reconcile failed for ${teamName} detail=${trigger.detail}: ${String(e)}`
);
} else {
logger.warn(
`[FileWatcher] reconcile failed for ${teamName} source=${trigger.source} detail=${trigger.detail}: ${String(e)}`
);
}
throw e;
}
},
})
: null;
// Forward team-change events to renderer and HTTP SSE
const teamChangeHandler = (event: unknown): void => {
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
@ -525,12 +550,8 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') {
if (teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
);
if (reconcileScheduler) {
reconcileScheduler.schedule(teamName, { source: 'inbox', detail });
}
// Relay inbox changes into active runtime recipients.
@ -588,11 +609,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Task change events: notify lead when teammate starts a task via CLI ---
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
);
reconcileScheduler?.schedule(teamName, { source: 'task', detail });
const taskId = detail.replace('.json', '');
void teamDataService
@ -625,7 +642,10 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
};
context.fileWatcher.on('team-change', teamChangeHandler);
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
teamChangeCleanup = () => {
context.fileWatcher.off('team-change', teamChangeHandler);
reconcileScheduler?.dispose();
};
logger.info(`FileWatcher events wired for context: ${context.id}`);
}

View file

@ -69,7 +69,7 @@ import {
TEAM_VALIDATE_CLI_ARGS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
@ -256,6 +256,20 @@ function buildLeadRosterContextBlock(
].join('\n');
}
function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | null {
if (actionMode !== 'delegate') return null;
return wrapAgentBlock(
[
'DELEGATE MODE USER ACK CONTRACT:',
'Before any task creation, delegation, or other tool use, begin your next assistant response with one short human-readable acknowledgement to the user.',
'That acknowledgement must be visible plain text, not only an agent-only block.',
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.',
'After that visible acknowledgement, continue with delegation/orchestration in the same turn.',
].join('\n')
);
}
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
@ -1676,6 +1690,7 @@ async function handleSendMessage(
const resolvedLeadName = leadName ?? memberName;
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
const delegateAckBlock = buildLeadDirectDelegateAckBlock(actionMode);
// Pre-generate stable messageId so both stdin and persistence use the same identity.
// This allows the lead to call task_create_from_message with the exact messageId.
const preGeneratedMessageId = crypto.randomUUID();
@ -1694,6 +1709,7 @@ async function handleSendMessage(
`You received a direct message from the user.`,
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
...(rosterContextBlock ? [rosterContextBlock] : []),
...(delegateAckBlock ? [delegateAckBlock] : []),
AGENT_BLOCK_OPEN,
`MessageId: ${preGeneratedMessageId}`,
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,

View file

@ -10,7 +10,7 @@ import { createLogger } from '@shared/utils/logger';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv';
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
const logger = createLogger('ClaudeMultimodelBridgeService');
@ -174,21 +174,7 @@ export class ClaudeMultimodelBridgeService {
}
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
const env = { ...this.buildCliEnv(binaryPath) };
delete env.CLAUDE_CODE_ENTRY_PROVIDER;
delete env.CLAUDE_CODE_USE_OPENAI;
delete env.CLAUDE_CODE_USE_BEDROCK;
delete env.CLAUDE_CODE_USE_VERTEX;
delete env.CLAUDE_CODE_USE_FOUNDRY;
delete env.CLAUDE_CODE_USE_GEMINI;
if (providerId === 'codex') {
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
} else if (providerId === 'gemini') {
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
}
return env;
return applyProviderRuntimeEnv({ ...this.buildCliEnv(binaryPath) }, providerId);
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {

View file

@ -2,7 +2,8 @@ import type { TeamProviderId } from '@shared/types';
import { ConfigManager } from '../infrastructure/ConfigManager';
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
const PROVIDER_ROUTING_ENV_KEYS = [
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
'CLAUDE_CODE_ENTRY_PROVIDER',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK',
@ -36,15 +37,17 @@ export function applyProviderRuntimeEnv(
const resolvedProvider: TeamProviderId =
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
for (const key of THIRD_PARTY_PROVIDER_ENV_KEYS) {
for (const key of PROVIDER_ROUTING_ENV_KEYS) {
env[key] = undefined;
}
if (resolvedProvider === 'codex') {
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
} else if (resolvedProvider === 'gemini') {
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
}
// Provider overrides must be positive pins. In dev and multimodel desktop
// flows the host process can already be routed to codex or gemini, and the
// child runtime reapplies settings.env after trust. Mark the env as
// host-managed and set the exact entry provider so anthropic teammates do not
// silently fall back into the host's current routing world.
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1';
env.CLAUDE_CODE_ENTRY_PROVIDER = resolvedProvider;
return env;
}

View file

@ -176,21 +176,6 @@ async function resolveFromCandidateList(candidates: string[]): Promise<string |
return null;
}
function getRepoLocalCliCandidates(): string[] {
if (process.platform === 'win32') {
return [];
}
const repoRoot = process.cwd();
return [
// Prefer an already compiled repo-local binary when available.
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
// Fall back to launcher scripts for normal local development.
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
];
}
let cachedPath: string | null | undefined;
/** Timestamp of last successful cache verification (ms). */
@ -257,9 +242,13 @@ export class ClaudeBinaryResolver {
}
if (flavor === 'free-code') {
const repoLocalCli = await resolveFromCandidateList(getRepoLocalCliCandidates());
if (repoLocalCli) {
cachedPath = repoLocalCli;
// Keep free-code resolution generic. Dev flows should inject an explicit
// CLAUDE_CLI_PATH, while non-dev setups can expose claude-multimodel on
// PATH without making this resolver guess a sibling repo name or folder.
const freeCodeBinaryName = 'claude-multimodel';
const fromPath = await resolveFromPathEnv(freeCodeBinaryName, enrichedPath);
if (fromPath) {
cachedPath = fromPath;
cacheVerifiedAt = Date.now();
return cachedPath;
}

View file

@ -112,6 +112,18 @@ interface TaskChangeLogSourceSnapshot {
logSourceGeneration: string | null;
}
interface FileWatchReconcileDiagnostics {
inFlight: number;
burstCount: number;
windowStartedAt: number;
lastPressureLogAt: number;
}
interface FileWatchReconcileTrigger {
source: 'inbox' | 'task';
detail?: string;
}
export class TeamDataService {
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
private processHealthTeams = new Set<string>();
@ -121,6 +133,7 @@ export class TeamDataService {
private taskCommentNotificationInFlight = new Set<string>();
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -509,6 +522,11 @@ export class TeamDataService {
const t = marks[label];
return typeof t === 'number' ? t - startedAt : -1;
};
const msBetween = (from: string, to: string): number => {
const fromTs = marks[from];
const toTs = marks[to];
return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1;
};
const config = await this.configReader.getConfig(teamName);
if (!config) {
@ -688,6 +706,7 @@ export class TeamDataService {
let messages: InboxMessage[] = messagesStepResult.value;
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
mark('postStart');
if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts];
@ -695,6 +714,7 @@ export class TeamDataService {
if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages];
}
mark('mergeMessages');
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
@ -717,6 +737,7 @@ export class TeamDataService {
return !leadSessionFingerprints.has(fp);
});
}
mark('dedupLeadTexts');
// Dedup exact message copies that can appear as both live lead_process rows and
// their persisted inbox/sent-message counterpart. If the messageId is identical,
@ -779,6 +800,7 @@ export class TeamDataService {
}
messages = [...dedupedWithoutId, ...dedupedById.values()];
}
mark('dedupMessageIds');
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug.
@ -826,11 +848,13 @@ export class TeamDataService {
}
}
}
mark('attachLeadSessionIds');
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
this.annotateSlashCommandResponses(messages);
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
mark('normalizeMessages');
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
const kanbanState: KanbanState = kanbanStateStepResult.value;
@ -840,8 +864,10 @@ export class TeamDataService {
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
mark('attachKanban');
const presenceIndex = await presenceIndexPromise;
mark('loadPresenceIndex');
const taskChangePresenceById = this.resolveTaskChangePresenceMap(
tasksWithKanbanBase,
@ -855,6 +881,7 @@ export class TeamDataService {
changePresence: taskChangePresenceById[task.id] ?? 'unknown',
}))
: tasksWithKanbanBase;
mark('changePresence');
const members = this.memberResolver.resolveMembers(
config,
@ -897,18 +924,45 @@ export class TeamDataService {
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
logger.warn(
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} runtimeAdvisories=${msSince(
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} post=${msBetween(
'postStart',
'mergeMessages'
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
'dedupLeadTexts',
'dedupMessageIds'
)}/attachLeadSession=${msBetween(
'dedupMessageIds',
'attachLeadSessionIds'
)}/normalizeMessages=${msBetween(
'attachLeadSessionIds',
'normalizeMessages'
)}/attachKanban=${msBetween(
'normalizeMessages',
'attachKanban'
)}/loadPresenceIndex=${msBetween(
'attachKanban',
'loadPresenceIndex'
)}/changePresence=${msBetween(
'loadPresenceIndex',
'changePresence'
)}/resolveMembers=${msBetween(
'changePresence',
'resolveMembers'
)}/runtimeAdvisories=${msBetween(
'resolveMembers',
'runtimeAdvisories'
)} enrichBranches=${msSince(
)}/enrichBranches=${msBetween(
'runtimeAdvisories',
'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
)}/processes=${msBetween('syncComments', 'processes')} ${counts}${
warnings.length > 0 ? ` warnings=${warnings.join('|')}` : ''
}`
);
}
@ -2231,10 +2285,79 @@ export class TeamDataService {
);
}
async reconcileTeamArtifacts(teamName: string): Promise<void> {
this.getController(teamName).maintenance.reconcileArtifacts({
reason: 'file-watch',
});
async reconcileTeamArtifacts(
teamName: string,
trigger?: FileWatchReconcileTrigger
): Promise<void> {
const now = Date.now();
const diagnostics = this.fileWatchReconcileDiagnostics.get(teamName) ?? {
inFlight: 0,
burstCount: 0,
windowStartedAt: now,
lastPressureLogAt: 0,
};
const triggerSource = trigger?.source ?? 'unknown';
const triggerDetail =
typeof trigger?.detail === 'string' && trigger.detail.trim().length > 0
? ` detail=${trigger.detail.trim()}`
: '';
if (now - diagnostics.windowStartedAt > 5_000) {
diagnostics.windowStartedAt = now;
diagnostics.burstCount = 0;
}
diagnostics.burstCount += 1;
diagnostics.inFlight += 1;
this.fileWatchReconcileDiagnostics.set(teamName, diagnostics);
const concurrentAtStart = diagnostics.inFlight;
const shouldLogPressure =
concurrentAtStart > 1 || diagnostics.burstCount >= 8 || diagnostics.burstCount === 1;
if (shouldLogPressure && now - diagnostics.lastPressureLogAt >= 2_000) {
diagnostics.lastPressureLogAt = now;
logger.warn(
`[reconcileTeamArtifacts] team=${teamName} reason=file-watch source=${triggerSource}${triggerDetail} inFlight=${concurrentAtStart} burst=${diagnostics.burstCount}`
);
}
const startedAt = Date.now();
try {
const rawResult = this.getController(teamName).maintenance.reconcileArtifacts({
reason: 'file-watch',
}) as
| {
staleKanbanEntriesRemoved?: number;
staleColumnOrderRefsRemoved?: number;
linkedCommentsCreated?: number;
}
| undefined;
const result = (rawResult ?? {}) as {
staleKanbanEntriesRemoved?: number;
staleColumnOrderRefsRemoved?: number;
linkedCommentsCreated?: number;
};
const durationMs = Date.now() - startedAt;
if (
durationMs >= 100 ||
concurrentAtStart > 1 ||
diagnostics.burstCount >= 8 ||
(result.linkedCommentsCreated ?? 0) > 0 ||
(result.staleKanbanEntriesRemoved ?? 0) > 0 ||
(result.staleColumnOrderRefsRemoved ?? 0) > 0
) {
logger.warn(
`[reconcileTeamArtifacts] completed team=${teamName} reason=file-watch source=${triggerSource}${triggerDetail} durationMs=${durationMs} inFlightAtStart=${concurrentAtStart} burst=${diagnostics.burstCount} linkedCommentsCreated=${result.linkedCommentsCreated ?? 0} staleKanbanEntriesRemoved=${result.staleKanbanEntriesRemoved ?? 0} staleColumnOrderRefsRemoved=${result.staleColumnOrderRefsRemoved ?? 0}`
);
}
} finally {
const current = this.fileWatchReconcileDiagnostics.get(teamName);
if (!current) {
return;
}
current.inFlight = Math.max(0, current.inFlight - 1);
if (current.inFlight === 0 && Date.now() - current.windowStartedAt > 30_000) {
this.fileWatchReconcileDiagnostics.delete(teamName);
}
}
}
private getLeadProjectDirCandidates(projectPath: string): string[] {

View file

@ -1,20 +1,35 @@
import * as fs from 'fs/promises';
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
import { createLogger } from '@shared/utils/logger';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
const LOOKBACK_MS = 10 * 60 * 1000;
const CACHE_TTL_MS = 5_000;
const TAIL_BYTES = 64 * 1024;
const BATCH_WARN_MS = 200;
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
interface CachedRuntimeAdvisory {
value: MemberRuntimeAdvisory | null;
expiresAt: number;
}
interface CachedTeamBatchAdvisories {
membersSignature: string;
value: Map<string, MemberRuntimeAdvisory>;
expiresAt: number;
}
export class TeamMemberRuntimeAdvisoryService {
private readonly cache = new Map<string, CachedRuntimeAdvisory>();
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
private readonly inFlightBatchRequests = new Map<
string,
Promise<Map<string, MemberRuntimeAdvisory>>
>();
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
@ -22,38 +37,162 @@ export class TeamMemberRuntimeAdvisoryService {
teamName: string,
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
): Promise<Map<string, MemberRuntimeAdvisory>> {
const advisoryEntries = await Promise.all(
members
.filter((member) => !member.removedAt)
.map(async (member) => {
const advisory = await this.getMemberAdvisory(teamName, member.name);
return advisory ? ([member.name, advisory] as const) : null;
})
);
const activeMembers = members.filter((member) => !member.removedAt);
if (activeMembers.length === 0) {
return new Map();
}
return new Map(
advisoryEntries.filter(
(entry): entry is readonly [string, MemberRuntimeAdvisory] => entry !== null
)
);
const teamKey = this.normalizeToken(teamName);
const membersSignature = this.buildMembersSignature(activeMembers);
const now = Date.now();
const cachedBatch = this.teamBatchCacheByTeam.get(teamKey);
if (
cachedBatch &&
cachedBatch.membersSignature === membersSignature &&
cachedBatch.expiresAt > now
) {
return this.materializeBatchAdvisories(activeMembers, cachedBatch.value);
}
const inFlightKey = `${teamKey}::${membersSignature}`;
const existingRequest = this.inFlightBatchRequests.get(inFlightKey);
if (existingRequest) {
return this.materializeBatchAdvisories(activeMembers, await existingRequest);
}
const request = this.loadBatchAdvisories(teamName, teamKey, activeMembers, membersSignature);
this.inFlightBatchRequests.set(inFlightKey, request);
try {
return this.materializeBatchAdvisories(activeMembers, await request);
} finally {
if (this.inFlightBatchRequests.get(inFlightKey) === request) {
this.inFlightBatchRequests.delete(inFlightKey);
}
}
}
async getMemberAdvisory(
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const cacheKey = `${teamName.toLowerCase()}::${memberName.toLowerCase()}`;
const cached = this.cache.get(cacheKey);
const cacheKey = this.getMemberCacheKey(teamName, memberName);
const cached = this.memberCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
return cached.value ? this.cloneAdvisory(cached.value) : null;
}
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
this.cache.set(cacheKey, {
this.memberCache.set(cacheKey, {
value: advisory,
expiresAt: Date.now() + CACHE_TTL_MS,
});
return advisory;
return advisory ? this.cloneAdvisory(advisory) : null;
}
private async loadBatchAdvisories(
teamName: string,
teamKey: string,
activeMembers: readonly Pick<ResolvedTeamMember, 'name'>[],
membersSignature: string
): Promise<Map<string, MemberRuntimeAdvisory>> {
const startedAt = performance.now();
const now = Date.now();
const result = new Map<string, MemberRuntimeAdvisory>();
const membersToFetch: string[] = [];
let memberCacheHits = 0;
let memberCacheMisses = 0;
for (const member of activeMembers) {
const normalizedMemberName = this.normalizeToken(member.name);
const cacheKey = `${teamKey}::${normalizedMemberName}`;
const cached = this.memberCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
memberCacheHits += 1;
if (cached.value) {
result.set(normalizedMemberName, this.cloneAdvisory(cached.value));
}
continue;
}
memberCacheMisses += 1;
membersToFetch.push(member.name);
}
if (membersToFetch.length > 0) {
const fetched = await Promise.all(
membersToFetch.map(async (memberName) => {
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
return [memberName, advisory] as const;
})
);
const fetchedAt = Date.now();
for (const [memberName, advisory] of fetched) {
const normalizedMemberName = this.normalizeToken(memberName);
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
value: advisory,
expiresAt: fetchedAt + CACHE_TTL_MS,
});
if (advisory) {
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
}
}
}
this.teamBatchCacheByTeam.set(teamKey, {
membersSignature,
value: this.cloneNormalizedAdvisories(result),
expiresAt: Date.now() + CACHE_TTL_MS,
});
const totalMs = performance.now() - startedAt;
if (totalMs >= BATCH_WARN_MS) {
logger.warn(
`[perf] getMemberAdvisories slow team=${teamName} activeMembers=${activeMembers.length} signatureMembers=${activeMembers.length} batchCache=miss memberCacheHits=${memberCacheHits} memberCacheMisses=${memberCacheMisses} fetchedMembers=${membersToFetch.length} total=${totalMs.toFixed(1)}ms`
);
}
return result;
}
private getMemberCacheKey(teamName: string, memberName: string): string {
return `${this.normalizeToken(teamName)}::${this.normalizeToken(memberName)}`;
}
private buildMembersSignature(members: readonly Pick<ResolvedTeamMember, 'name'>[]): string {
return Array.from(new Set(members.map((member) => this.normalizeToken(member.name))))
.sort()
.join('|');
}
private normalizeToken(value: string): string {
return value.trim().toLowerCase();
}
private cloneAdvisory(advisory: MemberRuntimeAdvisory): MemberRuntimeAdvisory {
return { ...advisory };
}
private cloneNormalizedAdvisories(
advisories: ReadonlyMap<string, MemberRuntimeAdvisory>
): Map<string, MemberRuntimeAdvisory> {
return new Map(
Array.from(advisories, ([memberName, advisory]) => [memberName, this.cloneAdvisory(advisory)])
);
}
private materializeBatchAdvisories(
activeMembers: readonly Pick<ResolvedTeamMember, 'name'>[],
advisories: ReadonlyMap<string, MemberRuntimeAdvisory>
): Map<string, MemberRuntimeAdvisory> {
const materialized = new Map<string, MemberRuntimeAdvisory>();
for (const member of activeMembers) {
const advisory = advisories.get(this.normalizeToken(member.name));
if (advisory) {
materialized.set(member.name, this.cloneAdvisory(advisory));
}
}
return materialized;
}
private async findRecentMemberAdvisory(

View file

@ -719,6 +719,38 @@ interface PromptSizeSummary {
const MEMBER_LAUNCH_GRACE_MS = 90_000;
export function shouldWarnOnUnreadableMemberAuditConfig(params: {
nowMs: number;
lastWarnAt: number;
expectedMembers: readonly string[];
memberSpawnStatuses: ReadonlyMap<
string,
Pick<MemberSpawnStatusEntry, 'agentToolAccepted' | 'firstSpawnAcceptedAt'> | undefined
>;
}): boolean {
const { nowMs, lastWarnAt, expectedMembers, memberSpawnStatuses } = params;
if (nowMs - lastWarnAt < MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) {
return false;
}
return expectedMembers.some((memberName) => {
const current = memberSpawnStatuses.get(memberName);
if (!current?.agentToolAccepted || typeof current.firstSpawnAcceptedAt !== 'string') {
return false;
}
const acceptedAtMs = Date.parse(current.firstSpawnAcceptedAt);
return Number.isFinite(acceptedAtMs) && nowMs - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
});
}
export function shouldWarnOnMissingRegisteredMember(params: {
nowMs: number;
lastWarnAt: number;
graceExpired: boolean;
}): boolean {
const { nowMs, lastWarnAt, graceExpired } = params;
return graceExpired && nowMs - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS;
}
function nowIso(): string {
return new Date().toISOString();
}
@ -3557,7 +3589,9 @@ export class TeamProvisioningService {
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Working directory does not exist: ${cwd}`);
// Allow the runtime probe to degrade a missing cwd into a warning.
// This keeps prepareForProvisioning side-effect free for future/missing paths.
return;
}
throw error;
}
@ -6258,8 +6292,12 @@ export class TeamProvisioningService {
if (!registeredNames) {
const now = Date.now();
if (
now - run.lastMemberSpawnAuditConfigReadWarningAt >=
MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS
shouldWarnOnUnreadableMemberAuditConfig({
nowMs: now,
lastWarnAt: run.lastMemberSpawnAuditConfigReadWarningAt,
expectedMembers: run.expectedMembers,
memberSpawnStatuses: run.memberSpawnStatuses,
})
) {
run.lastMemberSpawnAuditConfigReadWarningAt = now;
logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: config.json not readable`);
@ -6318,7 +6356,13 @@ export class TeamProvisioningService {
const now = Date.now();
const lastWarnAt = run.lastMemberSpawnAuditMissingWarningAt.get(expected) ?? 0;
if (now - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) {
if (
shouldWarnOnMissingRegisteredMember({
nowMs: now,
lastWarnAt,
graceExpired,
})
) {
run.lastMemberSpawnAuditMissingWarningAt.set(expected, now);
logger.warn(
`[${run.teamName}] Member "${expected}" not found in config.json members after provisioning`
@ -6413,7 +6457,8 @@ export class TeamProvisioningService {
private getFailedSpawnMembers(
run: ProvisioningRun
): { name: string; error?: string; updatedAt: string }[] {
return [...run.memberSpawnStatuses.entries()]
const memberSpawnStatuses = run.memberSpawnStatuses ?? new Map();
return [...memberSpawnStatuses.entries()]
.filter(([, entry]) => entry.launchState === 'failed_to_start')
.map(([name, entry]) => ({
name,
@ -6429,12 +6474,14 @@ export class TeamProvisioningService {
failedCount: number;
runtimeAlivePendingCount: number;
} {
const expectedMembers = run.expectedMembers ?? [];
const memberSpawnStatuses = run.memberSpawnStatuses ?? new Map();
let confirmedCount = 0;
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
for (const expected of run.expectedMembers) {
const entry = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
for (const expected of expectedMembers) {
const entry = memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
if (entry.launchState === 'confirmed_alive') {
confirmedCount += 1;
continue;
@ -9093,7 +9140,7 @@ export class TeamProvisioningService {
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
);
const hasPendingBootstrap =
!hasSpawnFailures && stillStartingCount > 0 && run.expectedMembers.length > 0;
!hasSpawnFailures && stillStartingCount > 0 && (run.expectedMembers?.length ?? 0) > 0;
const readyMessage = hasSpawnFailures
? `Launch completed with teammate errors — ${failedSpawnMembers
.map((member) => member.name)

View file

@ -0,0 +1,93 @@
import { yieldToEventLoop } from '@main/utils/asyncYield';
export interface TeamReconcileTrigger {
source: 'inbox' | 'task';
detail: string;
}
interface TeamReconcileDrainState {
running: boolean;
pending: boolean;
lastTrigger: TeamReconcileTrigger | null;
}
export interface TeamReconcileDrainScheduler {
schedule(teamName: string, trigger: TeamReconcileTrigger): void;
dispose(): void;
}
export function createTeamReconcileDrainScheduler(options: {
run: (teamName: string, trigger: TeamReconcileTrigger) => Promise<void>;
}): TeamReconcileDrainScheduler {
const states = new Map<string, TeamReconcileDrainState>();
let disposed = false;
const drainTeam = async (teamName: string): Promise<void> => {
const state = states.get(teamName);
if (!state || state.running || disposed) {
return;
}
state.running = true;
let failed = false;
try {
while (!disposed && state.pending) {
state.pending = false;
const trigger = state.lastTrigger;
if (!trigger) {
break;
}
try {
await options.run(teamName, trigger);
} catch (error) {
failed = true;
throw error;
} finally {
if (!disposed) {
await yieldToEventLoop();
}
}
}
} finally {
state.running = false;
if (disposed || !state.pending) {
states.delete(teamName);
return;
}
if (failed) {
void drainTeam(teamName).catch(() => undefined);
}
}
};
return {
schedule(teamName: string, trigger: TeamReconcileTrigger): void {
if (disposed) {
return;
}
const state = states.get(teamName) ?? {
running: false,
pending: false,
lastTrigger: null,
};
state.pending = true;
state.lastTrigger = trigger;
states.set(teamName, state);
if (state.running) {
return;
}
void drainTeam(teamName).catch(() => undefined);
},
dispose(): void {
disposed = true;
states.clear();
},
};
}

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -69,12 +69,12 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
// Main component
// =============================================================================
export const ClaudeLogsSection = ({
export const ClaudeLogsSection = memo(function ClaudeLogsSection({
teamName,
position = 'inline',
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element => {
}: ClaudeLogsSectionProps): React.JSX.Element {
const ctrl = useClaudeLogsController(teamName);
const [dialogOpen, setDialogOpen] = useState(false);
@ -151,4 +151,4 @@ export const ClaudeLogsSection = ({
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
</>
);
};
});

View file

@ -12,6 +12,8 @@ import { MemberHoverCard } from './members/MemberHoverCard';
interface MemberBadgeProps {
name: string;
color?: string;
/** Owning team context for hover-card store lookups. */
teamName?: string;
/** Avatar + badge size variant */
size?: 'xs' | 'sm' | 'md';
/** Hide the avatar icon, show only the name badge */
@ -30,6 +32,7 @@ interface MemberBadgeProps {
export const MemberBadge = ({
name,
color,
teamName,
size = 'sm',
hideAvatar,
onClick,
@ -93,7 +96,7 @@ export const MemberBadge = ({
}
return (
<MemberHoverCard name={name} color={color}>
<MemberHoverCard name={name} color={color} teamName={teamName}>
{content}
</MemberHoverCard>
);

View file

@ -1,9 +1,12 @@
import { useStore } from '@renderer/store';
import { memo } from 'react';
import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react';
import { MemberBadge } from './MemberBadge';
import type { ResolvedTeamMember, TeamProcess } from '@shared/types';
function formatShortTime(date: Date): string {
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
return distance
@ -23,15 +26,61 @@ function formatShortTime(date: Date): string {
.replace(' year', 'y');
}
export const ProcessesSection = (): React.JSX.Element | null => {
const teamName = useStore((s) => s.selectedTeamName);
const data = useStore((s) => s.selectedTeamData);
interface ProcessesSectionProps {
teamName: string;
members: ResolvedTeamMember[];
processes: TeamProcess[];
}
if (!teamName || !data?.processes?.length) return null;
function areMembersEquivalent(
left: readonly ResolvedTeamMember[],
right: readonly ResolvedTeamMember[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index].name !== right[index].name || left[index].color !== right[index].color) {
return false;
}
}
return true;
}
const memberColorMap = new Map(data.members.map((m) => [m.name, m.color]));
function areProcessesEquivalent(
left: readonly TeamProcess[],
right: readonly TeamProcess[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftProcess = left[index];
const rightProcess = right[index];
if (
leftProcess.id !== rightProcess.id ||
leftProcess.port !== rightProcess.port ||
leftProcess.url !== rightProcess.url ||
leftProcess.label !== rightProcess.label ||
leftProcess.pid !== rightProcess.pid ||
leftProcess.registeredBy !== rightProcess.registeredBy ||
leftProcess.registeredAt !== rightProcess.registeredAt ||
leftProcess.stoppedAt !== rightProcess.stoppedAt
) {
return false;
}
}
return true;
}
const sorted = [...data.processes].sort((a, b) => {
export const ProcessesSection = memo(function ProcessesSection({
teamName,
members,
processes,
}: ProcessesSectionProps): React.JSX.Element | null {
if (!teamName || processes.length === 0) return null;
const memberColorMap = new Map(members.map((m) => [m.name, m.color]));
const sorted = [...processes].sort((a, b) => {
const aAlive = !a.stoppedAt;
const bAlive = !b.stoppedAt;
if (aAlive !== bAlive) return aAlive ? -1 : 1;
@ -119,6 +168,7 @@ export const ProcessesSection = (): React.JSX.Element | null => {
<MemberBadge
name={proc.registeredBy}
color={memberColorMap.get(proc.registeredBy)}
teamName={teamName}
/>
)}
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
@ -128,4 +178,15 @@ export const ProcessesSection = (): React.JSX.Element | null => {
})}
</div>
);
};
}, areProcessesSectionPropsEqual);
function areProcessesSectionPropsEqual(
prev: Readonly<ProcessesSectionProps>,
next: Readonly<ProcessesSectionProps>
): boolean {
return (
prev.teamName === next.teamName &&
areMembersEquivalent(prev.members, next.members) &&
areProcessesEquivalent(prev.processes, next.processes)
);
}

View file

@ -70,7 +70,12 @@ export const TaskTooltip = ({
side = 'top',
}: TaskTooltipProps): React.JSX.Element => {
const selectedTeamName = useStore((s) => s.selectedTeamName);
const selectedTeamData = useStore((s) => s.selectedTeamData);
const selectedTeamData = useStore((s) => {
if (teamName) {
return s.selectedTeamName === teamName ? s.selectedTeamData : null;
}
return s.selectedTeamData;
});
const globalTasks = useStore((s) => s.globalTasks);
const teamByName = useStore((s) => s.teamByName);
@ -161,7 +166,11 @@ export const TaskTooltip = ({
{/* Owner */}
{task.owner && members.length > 0 ? (
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
<MemberBadge
name={task.owner}
color={colorMap.get(task.owner)}
teamName={resolvedTeamName}
/>
) : task.owner ? (
<span className="text-[10px] text-[var(--color-text-secondary)]">{task.owner}</span>
) : (

View file

@ -32,6 +32,7 @@ import {
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
@ -87,6 +88,10 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ProcessesSection } from './ProcessesSection';
import {
isLeadSessionMissing,
shouldSuppressMissingLeadSessionFetch,
} from './teamSessionFetchGuards';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
@ -117,6 +122,13 @@ interface CreateTaskDialogState {
defaultChip?: InlineChip;
}
const logger = createLogger('Component:TeamDetailView');
const TEAM_DETAIL_COMMIT_WARN_MS = 32;
const TEAM_DETAIL_RENDER_BURST_WINDOW_MS = 4_000;
const TEAM_DETAIL_RENDER_BURST_WARN_COUNT = 8;
const TEAM_DETAIL_RENDER_WARN_THROTTLE_MS = 2_000;
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
function areResolvedMembersEqual(
prev: readonly ResolvedTeamMember[],
next: readonly ResolvedTeamMember[]
@ -140,7 +152,12 @@ function areResolvedMembersEqual(
prevMember.effort !== nextMember.effort ||
prevMember.cwd !== nextMember.cwd ||
prevMember.gitBranch !== nextMember.gitBranch ||
prevMember.removedAt !== nextMember.removedAt
prevMember.removedAt !== nextMember.removedAt ||
prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind ||
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs ||
prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message
) {
return false;
}
@ -189,6 +206,13 @@ export const TeamDetailView = ({
teamName,
isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element => {
const renderStartedAtRef = useRef(performance.now());
const renderDiagnosticsRef = useRef({
windowStartedAt: Date.now(),
count: 0,
lastWarnAt: 0,
});
renderStartedAtRef.current = performance.now();
const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
@ -212,6 +236,7 @@ export const TeamDetailView = ({
const contentRef = useRef<HTMLDivElement>(null);
const provisioningBannerRef = useRef<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false);
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
// Set inert on background content when editor/graph overlay is open (a11y focus trap)
useEffect(() => {
@ -405,6 +430,7 @@ export const TeamDetailView = ({
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const missingLeadSessionFetchKeyRef = useRef<string | null>(null);
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
sessionId: null,
selectedOwners: new Set(),
@ -418,7 +444,6 @@ export const TeamDetailView = ({
error,
projects,
repositoryGroups,
teams,
fetchSessionDetail,
initTabUIState,
selectTeam,
@ -444,7 +469,7 @@ export const TeamDetailView = ({
provisioningError,
clearProvisioningError,
isTeamProvisioning,
leadActivityByTeam,
leadActivity,
leadContextUpdatedAt,
memberSpawnStatuses,
fetchMemberSpawnStatuses,
@ -464,12 +489,8 @@ export const TeamDetailView = ({
setSidebarLogsHeight,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
loading: s.selectedTeamLoading,
error: s.selectedTeamError,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
teams: s.teams,
fetchSessionDetail: s.fetchSessionDetail,
initTabUIState: s.initTabUIState,
selectTeam: s.selectTeam,
@ -495,7 +516,10 @@ export const TeamDetailView = ({
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
clearProvisioningError: s.clearProvisioningError,
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
leadActivityByTeam: s.leadActivityByTeam,
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined,
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
@ -528,6 +552,39 @@ export const TeamDetailView = ({
const isThisTabActive = tabId ? activeTabId === tabId : false;
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
useEffect(() => {
const now = Date.now();
const diagnostic = renderDiagnosticsRef.current;
if (now - diagnostic.windowStartedAt > TEAM_DETAIL_RENDER_BURST_WINDOW_MS) {
diagnostic.windowStartedAt = now;
diagnostic.count = 0;
}
diagnostic.count += 1;
const commitMs = performance.now() - renderStartedAtRef.current;
const messagesCount = data?.messages.length ?? 0;
const tasksCount = data?.tasks.length ?? 0;
const membersCount = data?.members.length ?? 0;
const processesCount = data?.processes.length ?? 0;
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
if (
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
now - diagnostic.lastWarnAt >= TEAM_DETAIL_RENDER_WARN_THROTTLE_MS
) {
diagnostic.lastWarnAt = now;
logger.warn(
`[perf] commit team=${teamName} ms=${commitMs.toFixed(1)} renders=${diagnostic.count} windowMs=${
now - diagnostic.windowStartedAt
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
loading ? 'yes' : 'no'
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
);
}
});
// Messages panel resize
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
useResizablePanel({
@ -565,7 +622,6 @@ export const TeamDetailView = ({
// Fetch initial spawn statuses when provisioning starts
useEffect(() => {
const leadActivity = teamName ? leadActivityByTeam[teamName] : undefined;
const shouldFetchSpawnStatuses =
Boolean(teamName) &&
(isTeamProvisioning ||
@ -578,7 +634,7 @@ export const TeamDetailView = ({
data?.isAlive,
fetchMemberSpawnStatuses,
isTeamProvisioning,
leadActivityByTeam,
leadActivity,
memberSpawnStatuses,
teamName,
]);
@ -644,12 +700,13 @@ export const TeamDetailView = ({
useEffect(() => {
if (!launchDialogOpen) return;
let cancelled = false;
const teamsSnapshot = useStore.getState().teams;
void (async () => {
try {
const aliveList = await api.teams.aliveList();
if (cancelled) return;
const aliveSet = new Set(aliveList);
const refs = teams
const refs = teamsSnapshot
.filter((t) => aliveSet.has(t.teamName) && t.projectPath)
.map((t) => ({
teamName: t.teamName,
@ -664,7 +721,7 @@ export const TeamDetailView = ({
return () => {
cancelled = true;
};
}, [launchDialogOpen, teams]);
}, [launchDialogOpen]);
useEffect(() => {
if (kanbanFilterQuery) {
@ -673,9 +730,22 @@ export const TeamDetailView = ({
}
}, [kanbanFilterQuery, clearKanbanFilter]);
const currentTeamSummary = useMemo(
() => teams.find((team) => team.teamName === teamName) ?? null,
[teams, teamName]
const currentTeamSummary = useStore(
useShallow((s) => {
const team = teamName ? s.teamByName[teamName] : undefined;
if (!team) return null;
return {
displayName: team.displayName,
projectPath: team.projectPath,
memberCount: team.memberCount,
expectedMemberCount: team.expectedMemberCount,
confirmedCount: team.confirmedCount,
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
teamLaunchState: team.teamLaunchState,
partialLaunchFailure: team.partialLaunchFailure,
missingMemberCount: team.missingMembers?.length ?? 0,
};
})
);
// Load sessions for the team's project
@ -695,6 +765,14 @@ export const TeamDetailView = ({
const leadSessionLoaded = Boolean(
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
);
const sessionHistoryKey = useMemo(
() => (data?.config.sessionHistory ?? []).join('|'),
[data?.config.sessionHistory]
);
const missingLeadSessionFetchKey = useMemo(
() => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`,
[teamName, projectId, leadSessionId, sessionHistoryKey]
);
const leadSubagentCostUsd = useMemo(() => {
const processes = leadSessionDetail?.processes;
@ -773,6 +851,10 @@ export const TeamDetailView = ({
[visibleContextTokens, lastAiGroupTotalTokens]
);
useEffect(() => {
missingLeadSessionFetchKeyRef.current = null;
}, [missingLeadSessionFetchKey]);
// Keep lead-session context fresh in the background while the team tab is active.
// This keeps the button value current even when the panel is closed.
// For offline teams: fetch once on mount so the percentage shows immediately.
@ -781,32 +863,76 @@ export const TeamDetailView = ({
if (!isThisTabActive) return;
if (!tabId || !projectId || !leadSessionId) return;
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
const leadSessionMissing = isLeadSessionMissing({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
});
if (leadSessionMissing) {
missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey;
return;
}
const fetchLeadSessionDetail = () => {
const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
suppressionKey: missingLeadSessionFetchKeyRef.current,
currentKey: missingLeadSessionFetchKey,
});
if (suppressRepeatedFetch) {
return;
}
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
};
fetchLeadSessionDetail();
if (!data?.isAlive) return;
const id = window.setInterval(() => {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
fetchLeadSessionDetail();
}, 10_000);
return () => window.clearInterval(id);
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
}, [
isThisTabActive,
tabId,
projectId,
leadSessionId,
data?.isAlive,
fetchSessionDetail,
sessions,
sessionsLoading,
missingLeadSessionFetchKey,
]);
// Keep team message state fresh while we are explicitly waiting for a reply.
// Direct lead replies land in the lead session JSONL first; without a light
// refresh loop here, the Messages panel can keep showing stale "awaiting reply"
// state even though the lead has already answered.
// Use a delayed single-shot refresh instead of a tight polling loop so we
// don't keep rewriting the whole team snapshot every 2 seconds.
useEffect(() => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
if (!isThisTabActive) return;
if (!data) return;
if (!data?.isAlive) return;
if (Object.keys(pendingRepliesByMember).length === 0) return;
void refreshTeamData(teamName);
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
pendingReplyRefreshTimerRef.current = null;
void refreshTeamData(teamName, { withDedup: true });
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
const id = window.setInterval(() => {
void refreshTeamData(teamName);
}, 2_000);
return () => window.clearInterval(id);
return () => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
};
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
useEffect(() => {
@ -933,25 +1059,25 @@ export const TeamDetailView = ({
);
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const taskMapRef = useRef(taskMap);
taskMapRef.current = taskMap;
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const openCreateTaskDialog = (
subject = '',
description = '',
owner = '',
startImmediately?: boolean
): void => {
setCreateTaskDialog({
open: true,
defaultSubject: subject,
defaultDescription: description,
defaultOwner: owner,
defaultStartImmediately: startImmediately,
});
};
const openCreateTaskDialog = useCallback(
(subject = '', description = '', owner = '', startImmediately?: boolean): void => {
setCreateTaskDialog({
open: true,
defaultSubject: subject,
defaultDescription: description,
defaultOwner: owner,
defaultStartImmediately: startImmediately,
});
},
[]
);
const closeCreateTaskDialog = (): void => {
const closeCreateTaskDialog = useCallback((): void => {
setCreateTaskDialog({
open: false,
defaultSubject: '',
@ -959,7 +1085,7 @@ export const TeamDetailView = ({
defaultOwner: '',
defaultStartImmediately: undefined,
});
};
}, []);
const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => {
openCreateTaskDialog(subject, description);
@ -977,6 +1103,36 @@ export const TeamDetailView = ({
setLaunchDialogOpen(true);
}, []);
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
setSelectedMember(member);
}, []);
const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => {
setSendDialogRecipient(member.name);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}, []);
const handleAssignTaskToMember = useCallback(
(member: ResolvedTeamMember) => {
openCreateTaskDialog('', '', member.name);
},
[openCreateTaskDialog]
);
const handleOpenTaskById = useCallback((taskId: string) => {
const task = taskMapRef.current.get(taskId);
if (task) {
setSelectedTask(task);
}
}, []);
const handleOpenTask = useCallback((task: TeamTaskWithKanban) => {
setSelectedTask(task);
}, []);
const handleTaskIdClick = useCallback(
(taskId: string) => {
const task =
@ -1172,6 +1328,50 @@ export const TeamDetailView = ({
})();
};
const sharedMessagesPanelProps = useMemo(
() => ({
teamName,
onTogglePosition: toggleMessagesPanelMode,
members: activeMembers,
tasks: data?.tasks ?? [],
messages: data?.messages ?? [],
isTeamAlive: data?.isAlive,
leadActivity,
leadContextUpdatedAt,
timeWindow,
teamSessionIds,
currentLeadSessionId: data?.config.leadSessionId,
pendingRepliesByMember,
onPendingReplyChange: setPendingRepliesByMember,
onMemberClick: handleSelectMember,
onTaskClick: handleOpenTask,
onCreateTaskFromMessage: handleCreateTaskFromMessage,
onReplyToMessage: handleReplyToMessage,
onRestartTeam: handleRestartTeam,
onTaskIdClick: handleTaskIdClick,
}),
[
activeMembers,
data?.config.leadSessionId,
data?.isAlive,
data?.messages,
data?.tasks,
handleCreateTaskFromMessage,
handleOpenTask,
handleReplyToMessage,
handleRestartTeam,
handleSelectMember,
handleTaskIdClick,
leadActivity,
leadContextUpdatedAt,
pendingRepliesByMember,
teamName,
teamSessionIds,
timeWindow,
toggleMessagesPanelMode,
]
);
if (!teamName) {
return (
<div className="flex size-full items-center justify-center p-6 text-sm text-red-400">
@ -1197,7 +1397,6 @@ export const TeamDetailView = ({
}
if (error === 'TEAM_DRAFT') {
const teamSummary = teams.find((t) => t.teamName === teamName);
return (
<>
<div className="size-full overflow-auto p-6">
@ -1208,10 +1407,11 @@ export const TeamDetailView = ({
<div className="max-w-md text-center">
<p className="text-sm font-medium text-text">Team not launched yet</p>
<p className="mt-2 text-xs text-text-secondary">
This is a draft team <strong>{teamSummary?.displayName || teamName}</strong> has
been configured with {teamSummary?.memberCount ?? 0} member
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI
yet. Click Launch to select a model and start the team.
This is a draft team {' '}
<strong>{currentTeamSummary?.displayName || teamName}</strong> has been configured
with {currentTeamSummary?.memberCount ?? 0} member
{currentTeamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned
by CLI yet. Click Launch to select a model and start the team.
</p>
<div className="mt-4 flex justify-center gap-2">
<button
@ -1237,7 +1437,7 @@ export const TeamDetailView = ({
open={launchDialogOpen}
teamName={teamName}
members={[]}
defaultProjectPath={teamSummary?.projectPath}
defaultProjectPath={currentTeamSummary?.projectPath}
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
onClose={() => setLaunchDialogOpen(false)}
@ -1276,27 +1476,6 @@ export const TeamDetailView = ({
const headerColorSet = data.config.color
? getTeamColorSet(data.config.color)
: nameColorSet(data.config.name);
const sharedMessagesPanelProps = {
teamName,
onTogglePosition: toggleMessagesPanelMode,
members: activeMembers,
tasks: data.tasks,
messages: data.messages,
isTeamAlive: data.isAlive,
leadActivity: leadActivityByTeam[teamName],
leadContextUpdatedAt,
timeWindow,
teamSessionIds,
currentLeadSessionId: data.config.leadSessionId,
pendingRepliesByMember,
onPendingReplyChange: setPendingRepliesByMember,
onMemberClick: setSelectedMember,
onTaskClick: setSelectedTask,
onCreateTaskFromMessage: handleCreateTaskFromMessage,
onReplyToMessage: handleReplyToMessage,
onRestartTeam: handleRestartTeam,
onTaskIdClick: handleTaskIdClick,
};
return (
<>
@ -1594,8 +1773,8 @@ export const TeamDetailView = ({
? `Last launch is still reconciling — ${currentTeamSummary.confirmedCount ?? 0}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.memberCount} teammates confirmed alive, ${currentTeamSummary.runtimeAlivePendingCount} runtime${currentTeamSummary.runtimeAlivePendingCount === 1 ? '' : 's'} pending bootstrap`
: 'Last launch is still reconciling'
: currentTeamSummary?.partialLaunchFailure
? currentTeamSummary.missingMembers?.length
? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join`
? currentTeamSummary.missingMemberCount > 0
? `Last launch failed partway — ${currentTeamSummary.missingMemberCount}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMemberCount} teammates did not join`
: 'Last launch failed partway'
: 'Team is offline'}
</span>
@ -1678,20 +1857,12 @@ export const TeamDetailView = ({
memberSpawnStatuses={memberSpawnStatusMap}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
leadActivity={leadActivity}
launchParams={launchParams}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
setSendDialogRecipient(member.name);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
onAssignTask={(member) => {
openCreateTaskDialog('', '', member.name);
}}
onOpenTask={(task) => setSelectedTask(task)}
onMemberClick={handleSelectMember}
onSendMessage={handleSendMessageToMember}
onAssignTask={handleAssignTaskToMember}
onOpenTask={handleOpenTaskById}
/>
</CollapsibleTeamSection>
@ -1922,7 +2093,11 @@ export const TeamDetailView = ({
}
defaultOpen
>
<ProcessesSection />
<ProcessesSection
teamName={teamName}
members={data.members}
processes={data.processes}
/>
</CollapsibleTeamSection>
)}
@ -1965,7 +2140,8 @@ export const TeamDetailView = ({
messages={data.messages}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
leadActivity={leadActivity}
spawnEntry={selectedMember ? memberSpawnStatusMap?.get(selectedMember.name) : undefined}
onClose={() => setSelectedMember(null)}
onSendMessage={() => {
const name = selectedMember?.name ?? '';

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@ -10,19 +10,43 @@ import { useShallow } from 'zustand/react/shallow';
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import { getDisplayStepIndex } from './provisioningSteps';
function formatRetryingRuntimePhrase(retryingRuntimeCount: number): string {
if (retryingRuntimeCount <= 0) {
return '';
}
return `${retryingRuntimeCount} teammate${retryingRuntimeCount === 1 ? '' : 's'} retrying provider capacity`;
}
function formatProcessOnlyAlivePhrase(
processOnlyAliveCount: number,
retryingRuntimeCount: number
): string {
if (processOnlyAliveCount <= 0) {
return '';
}
if (retryingRuntimeCount >= processOnlyAliveCount) {
return formatRetryingRuntimePhrase(processOnlyAliveCount);
}
const plainOnlineCount = processOnlyAliveCount - retryingRuntimeCount;
if (retryingRuntimeCount <= 0) {
return `${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`;
}
return `${formatRetryingRuntimePhrase(retryingRuntimeCount)}, ${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`;
}
interface TeamProvisioningBannerProps {
teamName: string;
}
export const TeamProvisioningBanner = ({
export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
teamName,
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
}: TeamProvisioningBannerProps): React.JSX.Element | null {
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } =
useStore(
useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName),
cancelProvisioning: s.cancelProvisioning,
teamMembers: s.selectedTeamData?.members,
teamMembers: s.selectedTeamName === teamName ? s.selectedTeamData?.members : undefined,
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
}))
@ -126,6 +150,14 @@ export const TeamProvisioningBanner = ({
const entry = memberSpawnStatuses?.[member.name];
return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true;
}).length;
const retryingRuntimeCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return (
entry?.launchState === 'runtime_pending_bootstrap' &&
entry.runtimeAlive === true &&
member.runtimeAdvisory?.kind === 'sdk_retrying'
);
}).length;
const pendingSpawnCount = snapshotSummary
? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount)
: teammates.filter((member) => {
@ -144,8 +176,16 @@ export const TeamProvisioningBanner = ({
heartbeatConfirmedCount === 0 &&
processOnlyAliveCount === fallbackTeammateCount &&
pendingSpawnCount === 0;
const hasMembersStillJoining =
fallbackTeammateCount > 0 &&
failedSpawnCount === 0 &&
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
if (isReady) {
const processOnlyAlivePhrase = formatProcessOnlyAlivePhrase(
processOnlyAliveCount,
retryingRuntimeCount
);
const readyDetailMessage =
failedSpawnCount > 0
? progress.message
@ -154,12 +194,14 @@ export const TeamProvisioningBanner = ({
: allTeammatesConfirmedAlive
? `Team provisioned — all ${fallbackTeammateCount} teammates made contact`
: allPendingRuntimesStarted
? 'Team provisioned — teammates online'
? processOnlyAlivePhrase
? `Team provisioned — ${processOnlyAlivePhrase}`
: 'Team provisioned — teammates online'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} teammate${processOnlyAliveCount === 1 ? '' : 's'} online` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team provisioned — teammates are still starting';
const readyDetailSeverity =
failedSpawnCount > 0 || pendingSpawnCount > 0 ? 'warning' : undefined;
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
const readyMessage =
failedSpawnCount > 0
? `Launch finished with errors — ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
@ -168,10 +210,13 @@ export const TeamProvisioningBanner = ({
: allTeammatesConfirmedAlive
? `Team launched — all ${fallbackTeammateCount} teammates made contact`
: allPendingRuntimesStarted
? 'Team launched — teammates online'
? processOnlyAlivePhrase
? `Team launched — ${processOnlyAlivePhrase}`
: 'Team launched — teammates online'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} teammate${processOnlyAliveCount === 1 ? '' : 's'} online` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team launched — teammates are still starting';
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
return (
<div className="mb-3">
@ -180,7 +225,7 @@ export const TeamProvisioningBanner = ({
title="Launch details"
message={failedSpawnCount > 0 ? readyDetailMessage : null}
messageSeverity={readyDetailSeverity}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
currentStepIndex={readyStepIndex}
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
@ -189,7 +234,7 @@ export const TeamProvisioningBanner = ({
onCancel={null}
successMessage={readyMessage}
successMessageSeverity={
failedSpawnCount > 0 || pendingSpawnCount > 0 ? 'warning' : 'success'
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : 'success'
}
onDismiss={() => setDismissed(true)}
/>
@ -225,4 +270,6 @@ export const TeamProvisioningBanner = ({
}
return null;
};
});
const DISPLAY_COMPLETE_STEP_INDEX = 4;

View file

@ -306,6 +306,7 @@ const NoiseRow = ({
);
const BootstrapSystemRow = ({
teamName,
senderName,
recipientName,
runtime,
@ -314,6 +315,7 @@ const BootstrapSystemRow = ({
timestamp,
onMemberNameClick,
}: {
teamName: string;
senderName: string;
recipientName: string;
runtime?: string;
@ -326,11 +328,18 @@ const BootstrapSystemRow = ({
<span className="bg-sky-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
start
</span>
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
@ -344,6 +353,7 @@ const BootstrapSystemRow = ({
);
const BootstrapAcknowledgementRow = ({
teamName,
senderName,
recipientName,
senderColor,
@ -351,6 +361,7 @@ const BootstrapAcknowledgementRow = ({
timestamp,
onMemberNameClick,
}: {
teamName: string;
senderName: string;
recipientName: string;
senderColor?: string;
@ -362,11 +373,18 @@ const BootstrapAcknowledgementRow = ({
<span className="bg-emerald-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-300">
bootstrap
</span>
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
@ -736,6 +754,7 @@ export const ActivityItem = memo(
if (bootstrapDisplay) {
return (
<BootstrapSystemRow
teamName={teamName}
senderName={senderName}
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
runtime={bootstrapDisplay.runtime}
@ -750,6 +769,7 @@ export const ActivityItem = memo(
if (bootstrapAcknowledgement) {
return (
<BootstrapAcknowledgementRow
teamName={teamName}
senderName={senderName}
recipientName={message.to ?? 'lead'}
senderColor={senderColor}
@ -875,6 +895,7 @@ export const ActivityItem = memo(
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar={senderHideAvatar || compactHeader}
onClick={onMemberNameClick}
disableHoverCard={crossTeamOrigin != null}
@ -962,6 +983,7 @@ export const ActivityItem = memo(
<MemberBadge
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
color={crossTeamTarget ? undefined : recipientColor}
teamName={crossTeamTarget ? undefined : teamName}
hideAvatar={
compactHeader ||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===

View file

@ -6,6 +6,7 @@ import {
areStringMapsEqual,
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { createLogger } from '@shared/utils/logger';
import { Layers } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
@ -80,6 +81,9 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const DEFAULT_COLLAPSE_MODE = 'default' as const;
const logger = createLogger('Component:ActivityTimeline');
const ACTIVITY_TIMELINE_SLICE_WARN_MS = 6;
const ACTIVITY_TIMELINE_GROUP_WARN_MS = 8;
interface ItemCollapseProps {
collapseMode: 'default' | 'managed';
@ -335,6 +339,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
// Pagination counts only significant (non-thought) messages so that lead thoughts
// don't consume the page limit — they collapse into a single visual group anyway.
const { visibleMessages, hiddenCount } = useMemo(() => {
const startedAt = performance.now();
const total = messages.length;
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
@ -354,14 +359,31 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
significantSeen +
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
const hidden = Math.max(0, significantTotal - visibleCount);
return {
const result = {
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
hiddenCount: hidden,
};
const ms = performance.now() - startedAt;
if (ms >= ACTIVITY_TIMELINE_SLICE_WARN_MS) {
logger.warn(
`[perf] paginate team=${teamName} ms=${ms.toFixed(1)} total=${messages.length} visible=${result.visibleMessages.length} hidden=${result.hiddenCount} pageSize=${visibleCount}`
);
}
return result;
}, [messages, visibleCount]);
// Group consecutive lead thoughts into collapsible blocks.
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
const timelineItems = useMemo(() => {
const startedAt = performance.now();
const result = groupTimelineItems(visibleMessages);
const ms = performance.now() - startedAt;
if (ms >= ACTIVITY_TIMELINE_GROUP_WARN_MS) {
logger.warn(
`[perf] groupTimeline team=${teamName} ms=${ms.toFixed(1)} visibleMessages=${visibleMessages.length} groups=${result.length}`
);
}
return result;
}, [teamName, visibleMessages]);
// Zebra striping is anchored from the bottom of the visible list so prepending
// new live messages at the top does not recolor every existing card.

View file

@ -6,10 +6,10 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
displayMemberName,
getLaunchAwarePresenceLabel,
getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle,
getSpawnAwareDotClass,
getSpawnAwarePresenceLabel,
getSpawnCardClass,
} from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
@ -83,23 +83,31 @@ export const MemberCard = ({
member,
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
const presenceLabel = getSpawnAwarePresenceLabel(
const presenceLabel = getLaunchAwarePresenceLabel(
member,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
member.runtimeAdvisory,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
const spawnCardClass = isTeamProvisioning ? getSpawnCardClass(spawnStatus) : '';
const spawnCardClass = getSpawnCardClass(
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isTeamAlive,
isTeamProvisioning
);
const colors = getTeamColorSet(memberColor);
const { isLight } = useTheme();
const pending = taskCounts?.pending ?? 0;
@ -113,6 +121,12 @@ export const MemberCard = ({
: reviewTask
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
: undefined;
const showStartingSkeleton =
!isRemoved &&
presenceLabel === 'starting' &&
spawnLaunchState !== 'failed_to_start' &&
!activityTask;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
return (
<div
@ -191,7 +205,18 @@ export const MemberCard = ({
</>
) : null}
</div>
{runtimeSummary ? (
{showStartingSkeleton ? (
<div className="mt-1 flex items-center gap-1.5" aria-hidden="true">
<div
className="skeleton-shimmer h-2 w-24 rounded-sm"
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
/>
<div
className="skeleton-shimmer h-2 w-16 rounded-sm"
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : runtimeSummary ? (
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary}
</div>
@ -205,7 +230,20 @@ export const MemberCard = ({
</span>
) : null;
})()}
{presenceLabel === 'connecting' || spawnStatus === 'spawning' ? (
{showStartingBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="starting"
/>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
starting
</Badge>
</span>
) : presenceLabel === 'connecting' ? (
!isRemoved ? (
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
@ -236,26 +274,42 @@ export const MemberCard = ({
{isRemoved ? 'removed' : presenceLabel}
</Badge>
) : null}
<div
className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
{showStartingSkeleton ? (
<div className="shrink-0" aria-hidden="true">
<div
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
style={{
backgroundColor: 'var(--skeleton-base-dim)',
borderColor: 'var(--color-border)',
}}
/>
<div
className="skeleton-shimmer mx-1 mt-1 h-[2px] w-10 rounded-full"
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : (
<div
className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
{totalTasks > 0 && (
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
</div>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
{totalTasks > 0 && (
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
</div>
)}
{!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>

View file

@ -17,6 +17,7 @@ import { MemberTasksTab } from './MemberTasksTab';
import type {
InboxMessage,
LeadActivityState,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
@ -30,6 +31,7 @@ interface MemberDetailDialogProps {
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
spawnEntry?: MemberSpawnStatusEntry;
onClose: () => void;
onSendMessage: () => void;
onAssignTask: () => void;
@ -49,6 +51,7 @@ export const MemberDetailDialog = ({
isTeamAlive,
isTeamProvisioning,
leadActivity,
spawnEntry,
onClose,
onSendMessage,
onAssignTask,
@ -100,6 +103,10 @@ export const MemberDetailDialog = ({
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
spawnStatus={spawnEntry?.status}
spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}

View file

@ -7,21 +7,31 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
displayMemberName,
getMemberDotClass,
getPresenceLabel,
getLaunchAwarePresenceLabel,
getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { isLeadMember } from '@shared/utils/leadDetection';
import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
import type {
LeadActivityState,
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
ResolvedTeamMember,
} from '@shared/types';
interface MemberDetailHeaderProps {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
spawnStatus?: MemberSpawnStatus;
spawnLaunchState?: MemberLaunchState;
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnRuntimeAlive?: boolean;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean;
}
@ -31,6 +41,10 @@ export const MemberDetailHeader = ({
isTeamAlive,
isTeamProvisioning,
leadActivity,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
@ -44,8 +58,26 @@ export const MemberDetailHeader = ({
const colors = getTeamColorSet(member.color ?? '');
const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getLaunchAwarePresenceLabel(
member,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
member.runtimeAdvisory,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
const dotClass = getSpawnAwareDotClass(
member,
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
const canEditRole =
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;

View file

@ -12,10 +12,10 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
displayMemberName,
getMemberDotClass,
getPresenceLabel,
getLaunchAwarePresenceLabel,
getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
@ -27,6 +27,8 @@ interface MemberHoverCardProps {
name: string;
/** Color key for the member */
color?: string;
/** Owning team context for store lookups. */
teamName?: string;
/** Called when user clicks on the current task */
onOpenTask?: (task: TeamTaskWithKanban) => void;
children: React.ReactNode;
@ -40,18 +42,34 @@ interface MemberHoverCardProps {
export const MemberHoverCard = ({
name,
color,
teamName,
onOpenTask,
children,
}: MemberHoverCardProps): React.JSX.Element => {
const { isLight } = useTheme();
const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null);
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive);
const teamName = useStore((s) => s.selectedTeamName);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const member = useStore((s) => {
if (!effectiveTeamName || s.selectedTeamName !== effectiveTeamName) return null;
return s.selectedTeamData?.members.find((m) => m.name === name) ?? null;
});
const isTeamAlive = useStore((s) =>
effectiveTeamName && s.selectedTeamName === effectiveTeamName
? s.selectedTeamData?.isAlive
: undefined
);
const spawnEntry = useStore((s) =>
effectiveTeamName ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined
);
const leadActivity: LeadActivityState | undefined = useStore((s) =>
teamName ? s.leadActivityByTeam[teamName] : undefined
effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const tasks = useStore((s) => s.selectedTeamData?.tasks);
const tasks = useStore((s) =>
effectiveTeamName && s.selectedTeamName === effectiveTeamName
? s.selectedTeamData?.tasks
: undefined
);
if (!member) {
return <>{children}</>;
@ -59,14 +77,22 @@ export const MemberHoverCard = ({
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(
const presenceLabel = getLaunchAwarePresenceLabel(
member,
spawnEntry?.status,
spawnEntry?.launchState,
spawnEntry?.livenessSource,
spawnEntry?.runtimeAlive,
member.runtimeAdvisory,
isTeamAlive,
false,
isLeadMember(member) ? leadActivity : undefined
);
const dotClass = getMemberDotClass(
const dotClass = getSpawnAwareDotClass(
member,
spawnEntry?.status,
spawnEntry?.launchState,
spawnEntry?.runtimeAlive,
isTeamAlive,
false,
isLeadMember(member) ? leadActivity : undefined

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
formatTeamModelSummary,
@ -33,10 +33,165 @@ interface MemberListProps {
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
onOpenTask?: (task: TeamTaskWithKanban) => void;
onOpenTask?: (taskId: string) => void;
}
export const MemberList = ({
function areResolvedMembersEquivalent(
left: readonly ResolvedTeamMember[],
right: readonly ResolvedTeamMember[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftMember = left[index];
const rightMember = right[index];
if (
leftMember.name !== rightMember.name ||
leftMember.status !== rightMember.status ||
leftMember.currentTaskId !== rightMember.currentTaskId ||
leftMember.taskCount !== rightMember.taskCount ||
leftMember.color !== rightMember.color ||
leftMember.agentType !== rightMember.agentType ||
leftMember.role !== rightMember.role ||
leftMember.workflow !== rightMember.workflow ||
leftMember.providerId !== rightMember.providerId ||
leftMember.model !== rightMember.model ||
leftMember.effort !== rightMember.effort ||
leftMember.cwd !== rightMember.cwd ||
leftMember.gitBranch !== rightMember.gitBranch ||
leftMember.removedAt !== rightMember.removedAt ||
leftMember.runtimeAdvisory?.kind !== rightMember.runtimeAdvisory?.kind ||
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs ||
leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message
) {
return false;
}
}
return true;
}
function areTaskStatusCountsMapsEquivalent(
left: Map<string, TaskStatusCounts> | undefined,
right: Map<string, TaskStatusCounts> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftCounts] of left) {
const rightCounts = right.get(key);
if (
!rightCounts ||
leftCounts.pending !== rightCounts.pending ||
leftCounts.inProgress !== rightCounts.inProgress ||
leftCounts.completed !== rightCounts.completed
) {
return false;
}
}
return true;
}
function areMemberTaskMapsEquivalent(
left: Map<string, TeamTaskWithKanban> | undefined,
right: Map<string, TeamTaskWithKanban> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftTask] of left) {
const rightTask = right.get(key);
if (
!rightTask ||
leftTask.id !== rightTask.id ||
leftTask.displayId !== rightTask.displayId ||
leftTask.subject !== rightTask.subject ||
leftTask.owner !== rightTask.owner ||
leftTask.status !== rightTask.status ||
leftTask.reviewer !== rightTask.reviewer ||
leftTask.reviewState !== rightTask.reviewState ||
leftTask.kanbanColumn !== rightTask.kanbanColumn
) {
return false;
}
}
return true;
}
function arePendingRepliesEquivalent(
left: Record<string, number> | undefined,
right: Record<string, number> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (left[key] !== right[key]) {
return false;
}
}
return true;
}
function areMemberSpawnStatusesEquivalent(
left: Map<string, MemberSpawnStatusEntry> | undefined,
right: Map<string, MemberSpawnStatusEntry> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) {
const rightEntry = right.get(key);
if (
!rightEntry ||
leftEntry.status !== rightEntry.status ||
leftEntry.launchState !== rightEntry.launchState ||
leftEntry.error !== rightEntry.error ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
) {
return false;
}
}
return true;
}
function areLaunchParamsEquivalent(
left: TeamLaunchParams | undefined,
right: TeamLaunchParams | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.providerId === right.providerId &&
left.model === right.model &&
left.effort === right.effort
);
}
function areMemberListPropsEqual(
prev: Readonly<MemberListProps>,
next: Readonly<MemberListProps>
): boolean {
return (
areResolvedMembersEquivalent(prev.members, next.members) &&
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
);
}
export const MemberList = memo(function MemberList({
members,
memberTaskCounts,
taskMap,
@ -50,7 +205,7 @@ export const MemberList = ({
onSendMessage,
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element => {
}: MemberListProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isWide, setIsWide] = useState(false);
@ -111,7 +266,7 @@ export const MemberList = ({
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name);
return (
<MemberCard
@ -132,8 +287,8 @@ export const MemberList = ({
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
@ -156,4 +311,4 @@ export const MemberList = ({
)}
</div>
);
};
}, areMemberListPropsEqual);

View file

@ -174,8 +174,13 @@ export const MessageComposer = ({
}
}, [members, recipient]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const projectPath = useStore((s) =>
s.selectedTeamName === teamName ? (s.selectedTeamData?.config.projectPath ?? null) : null
);
const currentTeamColor = useStore((s) => {
if (s.selectedTeamName !== teamName) {
return nameColorSet(teamName).border;
}
const configColor = s.selectedTeamData?.config.color;
if (configColor) return getTeamColorSet(configColor).border;
const displayName = s.selectedTeamData?.config.name ?? teamName;

View file

@ -9,7 +9,7 @@ import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
export interface MessagesFilterState {
from: Set<string>;
@ -19,6 +19,8 @@ export interface MessagesFilterState {
}
interface MessagesFilterPopoverProps {
teamName: string;
members: ResolvedTeamMember[];
filter: MessagesFilterState;
messages: InboxMessage[];
open: boolean;
@ -43,6 +45,8 @@ function collectToOptions(messages: InboxMessage[]): string[] {
}
export const MessagesFilterPopover = ({
teamName,
members,
filter,
messages,
open,
@ -67,7 +71,6 @@ export const MessagesFilterPopover = ({
}
}, [open, filter.from, filter.to, filter.showNoise]);
const members = useStore((s) => s.selectedTeamData?.members ?? []);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
@ -151,6 +154,7 @@ export const MessagesFilterPopover = ({
<MemberBadge
name={name}
color={colorMap.get(name)}
teamName={teamName}
size="sm"
hideAvatar={name === 'user'}
/>
@ -177,6 +181,7 @@ export const MessagesFilterPopover = ({
<MemberBadge
name={name}
color={colorMap.get(name)}
teamName={teamName}
size="sm"
hideAvatar={name === 'user'}
/>

View file

@ -9,6 +9,7 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { createLogger } from '@shared/utils/logger';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import {
CheckCheck,
@ -44,6 +45,10 @@ interface TimeWindow {
end: number;
}
const logger = createLogger('Component:MessagesPanel');
const MESSAGES_PANEL_FILTER_WARN_MS = 8;
const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6;
interface MessagesPanelProps {
teamName: string;
position: 'sidebar' | 'inline';
@ -183,20 +188,40 @@ export const MessagesPanel = memo(function MessagesPanel({
}, [position, sidebarScrollTop]);
const filteredMessages = useMemo(() => {
return filterTeamMessages(messages, {
const startedAt = performance.now();
const result = filterTeamMessages(messages, {
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
logger.warn(
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
messagesFilter.showNoise ? 'on' : 'off'
}`
);
}
return result;
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
const activityTimelineMessages = useMemo(() => {
return filterTeamMessages(messages, {
const startedAt = performance.now();
const result = filterTeamMessages(messages, {
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
logger.warn(
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
messagesFilter.showNoise ? 'on' : 'off'
}`
);
}
return result;
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
const replyCandidateMessages = useMemo(
@ -211,18 +236,32 @@ export const MessagesPanel = memo(function MessagesPanel({
// Resolve the expanded item from filtered messages
const expandedItem = useMemo<TimelineItem | null>(() => {
const startedAt = performance.now();
if (!expandedItemKey) return null;
if (!expandedItemKey.startsWith('thoughts-')) {
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
return msg ? { type: 'message', message: msg } : null;
const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null;
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
logger.warn(
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}`
);
}
return result;
}
const allItems = groupTimelineItems(activityTimelineMessages);
return (
const result =
allItems.find(
(item) =>
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
) ?? null
);
) ?? null;
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
logger.warn(
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}`
);
}
return result;
}, [expandedItemKey, activityTimelineMessages]);
// Auto-clear stale expanded key
@ -361,6 +400,8 @@ export const MessagesPanel = memo(function MessagesPanel({
)}
</div>
<MessagesFilterPopover
teamName={teamName}
members={members}
filter={messagesFilter}
messages={messages}
open={messagesFilterOpen}

View file

@ -1,7 +1,7 @@
import { memo, useState } from 'react';
import { ClaudeLogsSection } from '../ClaudeLogsSection';
import { MessagesPanel } from '../messages/MessagesPanel';
import { useState } from 'react';
import type { MouseEventHandler } from 'react';
import type { ComponentProps } from 'react';
@ -17,7 +17,7 @@ interface TeamSidebarRailProps {
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
}
export const TeamSidebarRail = ({
export const TeamSidebarRail = memo(function TeamSidebarRail({
teamName,
messagesPanelProps,
isResizing,
@ -25,7 +25,7 @@ export const TeamSidebarRail = ({
logsHeight,
isLogsResizing,
onLogsResizeMouseDown,
}: TeamSidebarRailProps): React.JSX.Element => {
}: TeamSidebarRailProps): React.JSX.Element {
const [logsOpen, setLogsOpen] = useState(false);
const logsSeparator = logsOpen ? (
<div
@ -64,4 +64,4 @@ export const TeamSidebarRail = ({
/>
</div>
);
};
});

View file

@ -0,0 +1,26 @@
import type { Session } from '@renderer/types/data';
export function isLeadSessionMissing(params: {
leadSessionId: string | null;
projectId: string | null;
sessionsLoading: boolean;
knownSessions: readonly Pick<Session, 'id'>[];
}): boolean {
const { leadSessionId, projectId, sessionsLoading, knownSessions } = params;
if (!leadSessionId || !projectId || sessionsLoading || knownSessions.length === 0) {
return false;
}
return !knownSessions.some((session) => session.id === leadSessionId);
}
export function shouldSuppressMissingLeadSessionFetch(params: {
leadSessionId: string | null;
projectId: string | null;
sessionsLoading: boolean;
knownSessions: readonly Pick<Session, 'id'>[];
suppressionKey: string | null;
currentKey: string;
}): boolean {
const { suppressionKey, currentKey } = params;
return suppressionKey === currentKey && isLeadSessionMissing(params);
}

View file

@ -380,7 +380,9 @@ function createDefaultViewerState(): ClaudeLogsViewerState {
// =============================================================================
export function useClaudeLogsController(teamName: string): ClaudeLogsController {
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const isAlive = useStore((s) =>
s.selectedTeamName === teamName ? (s.selectedTeamData?.isAlive ?? false) : false
);
// ── Data state ────────────────────────────────────────────────────────
const [loadedCount, setLoadedCount] = useState(PAGE_SIZE);

View file

@ -44,6 +44,13 @@ function formatToolPreview(preview: string | undefined): string | undefined {
return preview.length > 50 ? preview.slice(0, 50) + '...' : preview;
}
function getSpawnStatusBadgeLabel(spawnStatus: GraphNode['spawnStatus']): string {
if (spawnStatus === 'waiting' || spawnStatus === 'spawning') {
return 'starting';
}
return spawnStatus ?? '';
}
interface GraphNodePopoverProps {
node: GraphNode;
teamName: string;
@ -246,7 +253,7 @@ const MemberPopoverContent = ({
variant="outline"
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
>
{node.spawnStatus}
{getSpawnStatusBadgeLabel(node.spawnStatus)}
</Badge>
)}
</div>

View file

@ -11,6 +11,7 @@ import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { create } from 'zustand';
@ -32,7 +33,11 @@ import { createSessionSlice } from './slices/sessionSlice';
import { createSubagentSlice } from './slices/subagentSlice';
import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import { createTeamSlice } from './slices/teamSlice';
import {
createTeamSlice,
getLastResolvedTeamDataRefreshAt,
isTeamDataRefreshPending,
} from './slices/teamSlice';
import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice';
@ -54,8 +59,72 @@ 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;
const TEAM_CHANGE_EVENT_BURST_WINDOW_MS = 4_000;
const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const CURRENT_APP_VERSION =
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
const logger = createLogger('Store:index');
const RELEVANT_TEAM_CHANGE_EVENT_TYPES = new Set<TeamChangeEvent['type']>([
'task',
'config',
'inbox',
'lead-message',
'lead-context',
'lead-activity',
'process',
'member-spawn',
]);
const teamChangeEventDiagnostics = new Map<
string,
{
windowStartedAt: number;
count: number;
lastWarnAt: number;
countsByType: Record<string, number>;
}
>();
function noteTeamChangeEventBurst(teamName: string, eventType: string, visible: boolean): void {
if (!visible) return;
const now = Date.now();
const diagnostic = teamChangeEventDiagnostics.get(teamName) ?? {
windowStartedAt: now,
count: 0,
lastWarnAt: 0,
countsByType: {},
};
if (now - diagnostic.windowStartedAt > TEAM_CHANGE_EVENT_BURST_WINDOW_MS) {
diagnostic.windowStartedAt = now;
diagnostic.count = 0;
diagnostic.countsByType = {};
}
diagnostic.count += 1;
diagnostic.countsByType[eventType] = (diagnostic.countsByType[eventType] ?? 0) + 1;
if (
diagnostic.count >= TEAM_CHANGE_EVENT_BURST_WARN_COUNT &&
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
) {
diagnostic.lastWarnAt = now;
const typeSummary = Object.entries(diagnostic.countsByType)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${type}:${count}`)
.join(',');
logger.warn(
`[perf] team-change burst team=${teamName} total=${diagnostic.count} windowMs=${
now - diagnostic.windowStartedAt
} types=${typeSummary}`
);
}
teamChangeEventDiagnostics.set(teamName, diagnostic);
}
// =============================================================================
// Store Creation
@ -166,6 +235,8 @@ export function initializeNotificationListeners(): () => void {
});
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const teamLastRelevantActivityAt = new Map<string, number>();
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -182,6 +253,19 @@ export function initializeNotificationListeners(): () => void {
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
if (memberSpawnRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
memberSpawnRefreshTimers.delete(teamName);
void useStore.getState().fetchMemberSpawnStatuses(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer);
};
const buildToolActivityTimerKey = (
teamName: string,
memberName: string,
@ -500,6 +584,83 @@ export function initializeNotificationListeners(): () => void {
return tracked;
};
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
teamLastRelevantActivityAt.set(teamName, timestamp);
};
const getFocusedVisibleTeamName = (): string | null => {
const state = useStore.getState();
const focusedPane = state.paneLayout.panes.find(
(pane) => pane.id === state.paneLayout.focusedPaneId
);
if (!focusedPane?.activeTabId) {
return null;
}
const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId);
if (activeTab?.type !== 'team' || !activeTab.teamName) {
return null;
}
if (state.selectedTeamName !== activeTab.teamName) {
return null;
}
if (state.selectedTeamData?.teamName !== activeTab.teamName) {
return null;
}
return activeTab.teamName;
};
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
const current = useStore.getState();
const teamName = getFocusedVisibleTeamName();
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
if (current.selectedTeamLoading) {
return;
}
if (isTeamDataRefreshPending(teamName)) {
return;
}
const lastRelevantActivityAt = teamLastRelevantActivityAt.get(teamName) ?? 0;
const lastResolvedRefreshAt = getLastResolvedTeamDataRefreshAt(teamName) ?? 0;
const idleBaselineAt = Math.max(lastRelevantActivityAt, lastResolvedRefreshAt);
if (idleBaselineAt === 0) {
return;
}
const now = Date.now();
if (now - idleBaselineAt < TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS) {
return;
}
const lastWatchdogRefreshAt = teamLastIdleWatchdogRefreshAt.get(teamName) ?? 0;
if (lastWatchdogRefreshAt >= idleBaselineAt) {
return;
}
logger.warn(`[perf] idle-watchdog refresh team=${teamName} idleMs=${now - idleBaselineAt}`);
try {
await current.refreshTeamData(teamName, { withDedup: true });
} finally {
teamLastIdleWatchdogRefreshAt.set(
teamName,
Math.max(getLastResolvedTeamDataRefreshAt(teamName) ?? 0, idleBaselineAt, Date.now())
);
}
};
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
@ -681,8 +842,18 @@ export function initializeNotificationListeners(): () => void {
}
}
const teamIdleWatchdogTimer = setInterval(() => {
void pollFocusedVisibleTeamIdleWatchdog();
}, TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS);
cleanupFns.push(() => {
clearInterval(teamIdleWatchdogTimer);
});
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName);
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam);
const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false;
const state = useStore.getState();
@ -719,6 +890,10 @@ export function initializeNotificationListeners(): () => void {
}
};
if (RELEVANT_TEAM_CHANGE_EVENT_TYPES.has(event.type) && !isStaleRuntimeEvent) {
noteRelevantTeamActivity(event.teamName);
}
// Immediate in-memory update for lead activity — no filesystem refresh needed
if (event.type === 'lead-activity' && event.detail) {
if (isStaleRuntimeEvent) {
@ -917,22 +1092,12 @@ export function initializeNotificationListeners(): () => void {
return;
}
seedCurrentRunIdIfMissing();
void useStore.getState().fetchMemberSpawnStatuses(event.teamName);
scheduleMemberSpawnStatusesRefresh(event.teamName);
return;
}
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
if (memberSpawnRefreshTimers.has(event.teamName)) {
return;
}
const timer = setTimeout(() => {
memberSpawnRefreshTimers.delete(event.teamName);
void useStore.getState().fetchMemberSpawnStatuses(event.teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(event.teamName, timer);
scheduleMemberSpawnStatusesRefresh(event.teamName);
}
// Live lead-message events: only refresh the visible team detail, not team/task lists.
@ -951,7 +1116,7 @@ export function initializeNotificationListeners(): () => void {
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
void current.refreshTeamData(event.teamName);
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
return;
@ -981,8 +1146,10 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_LIST_REFRESH_THROTTLE_MS);
}
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
// Throttled refresh of global tasks list for sidebar.
if (!globalTasksRefreshTimer) {
if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) {
globalTasksRefreshTimer = setTimeout(() => {
globalTasksRefreshTimer = null;
void useStore.getState().fetchAllTasks();
@ -1002,7 +1169,7 @@ export function initializeNotificationListeners(): () => void {
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
void current.refreshTeamData(event.teamName);
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
});
@ -1018,6 +1185,8 @@ export function initializeNotificationListeners(): () => void {
memberSpawnRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
teamLastRelevantActivityAt.clear();
teamLastIdleWatchdogRefreshAt.clear();
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;

View file

@ -107,6 +107,20 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
error: 'spawn failed',
};
function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
runtimeAlive: boolean | undefined
): boolean {
if (spawnLaunchState === 'failed_to_start') {
return false;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return false;
}
return spawnLaunchState === 'starting' || spawnStatus === 'waiting' || spawnStatus === 'spawning';
}
/**
* Returns dot class for a member during provisioning, respecting spawn status.
* Falls back to the existing `getMemberDotClass` when no spawn status is available.
@ -115,13 +129,20 @@ export function getSpawnAwareDotClass(
member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
runtimeAlive: boolean | undefined,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (isTeamAlive === false && !isTeamProvisioning) {
return STATUS_DOT_COLORS.terminated;
}
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_DOT_COLORS.error;
}
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
return spawnStatus === 'spawning' ? SPAWN_DOT_COLORS.spawning : SPAWN_DOT_COLORS.waiting;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
return SPAWN_DOT_COLORS.online;
}
@ -153,17 +174,17 @@ export function getSpawnAwarePresenceLabel(
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (isTeamAlive === false && !isTeamProvisioning) {
return 'offline';
}
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_PRESENCE_LABELS.error;
}
if (spawnStatus === 'offline' && isTeamProvisioning) {
return 'waiting for Agent';
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return 'online';
}
if (spawnStatus === 'waiting') {
return SPAWN_PRESENCE_LABELS.waiting;
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
return 'starting';
}
if (spawnStatus === 'online' && livenessSource === 'process') {
return 'online';
@ -178,20 +199,31 @@ export function getSpawnAwarePresenceLabel(
* Card container CSS classes based on spawn status (opacity + animation).
* Used by MemberCard wrapper for fade-in transitions.
*/
export function getSpawnCardClass(spawnStatus: MemberSpawnStatus | undefined): string {
export function getSpawnCardClass(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState?: MemberLaunchState,
runtimeAlive?: boolean,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean
): string {
if (isTeamAlive === false && !isTeamProvisioning) {
return 'opacity-40';
}
switch (spawnStatus) {
case 'offline':
return 'opacity-40';
return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40';
case 'waiting':
return 'member-waiting-shimmer';
case 'spawning':
return '';
return 'member-waiting-shimmer';
case 'online':
return 'animate-[member-fade-in_0.4s_ease-out]';
case 'error':
return 'opacity-80';
default:
return '';
return isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)
? 'member-waiting-shimmer'
: '';
}
}
@ -235,6 +267,36 @@ export function getMemberRuntimeAdvisoryTitle(
);
}
export function getLaunchAwarePresenceLabel(
member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
livenessSource: MemberSpawnLivenessSource | undefined,
runtimeAlive: boolean | undefined,
runtimeAdvisory: MemberRuntimeAdvisory | undefined,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
const advisoryLabel =
spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory)
: null;
if (advisoryLabel) {
return advisoryLabel;
}
return getSpawnAwarePresenceLabel(
member,
spawnStatus,
spawnLaunchState,
livenessSource,
runtimeAlive,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
}
export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },

View file

@ -340,6 +340,29 @@ describe('ipc teams handlers', () => {
);
});
it('adds a visible-first acknowledgement contract for live lead delegate turns', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'Delegate this work',
actionMode: 'delegate',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('DELEGATE MODE USER ACK CONTRACT:'),
undefined
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'),
undefined
);
});
it('omits roster context when durable teammate roster is empty', async () => {
mockGetMembersMeta.mockResolvedValueOnce([]);
service.getTeamData.mockResolvedValueOnce({

View file

@ -7,7 +7,7 @@ import {
} from '@main/services/runtime/providerRuntimeEnv';
describe('providerRuntimeEnv', () => {
it('enables Gemini runtime mode and clears other third-party provider flags', () => {
it('pins gemini runtime mode and marks provider routing as host-managed', () => {
const env: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
CLAUDE_CODE_USE_GEMINI: undefined,
@ -16,11 +16,25 @@ describe('providerRuntimeEnv', () => {
const result = applyProviderRuntimeEnv(env, 'gemini');
expect(result.CLAUDE_CODE_USE_GEMINI).toBe('1');
expect(result.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1');
expect(result.CLAUDE_CODE_ENTRY_PROVIDER).toBe('gemini');
expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined();
expect(result.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
});
it('pins anthropic explicitly instead of relying on default provider fallback', () => {
const env: NodeJS.ProcessEnv = {
CLAUDE_CODE_ENTRY_PROVIDER: 'codex',
CLAUDE_CODE_USE_OPENAI: '1',
};
const result = applyProviderRuntimeEnv(env, 'anthropic');
expect(result.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1');
expect(result.CLAUDE_CODE_ENTRY_PROVIDER).toBe('anthropic');
expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined();
});
it('preserves gemini as a valid team provider id', () => {
expect(resolveTeamProviderId('gemini')).toBe('gemini');
expect(resolveTeamProviderId('codex')).toBe('codex');

View file

@ -11,7 +11,6 @@ const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>()
const statMock = vi.fn<
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
>();
const readdirMock = vi.fn<(filePath: PathLike) => Promise<string[]>>();
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
@ -32,14 +31,12 @@ vi.mock('fs', () => ({
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
readdir: (filePath: PathLike) => readdirMock(filePath),
},
},
constants: { X_OK: 1 },
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
readdir: (filePath: PathLike) => readdirMock(filePath),
},
}));
@ -55,7 +52,6 @@ describe('ClaudeBinaryResolver', () => {
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockResolveInteractiveShellEnv.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
readdirMock.mockResolvedValue([]);
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
@ -75,8 +71,26 @@ describe('ClaudeBinaryResolver', () => {
vi.unstubAllEnvs();
});
it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli';
it('resolves free-code runtime from an explicit CLAUDE_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev';
process.env.CLAUDE_CLI_PATH = expectedBinary;
accessMock.mockImplementation(async (filePath) => {
if (filePath === expectedBinary) {
return;
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('falls back to claude-multimodel on PATH for free-code runtime', async () => {
const expectedBinary = '/usr/local/bin/claude-multimodel';
accessMock.mockImplementation(async (filePath) => {
if (filePath === expectedBinary) {

View file

@ -351,6 +351,17 @@ function createGetTeamDataHarness(options: {
};
}
function buildResolvedMember(name: string): TeamData['members'][number] {
return {
name,
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
}
describe('TeamDataService', () => {
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = [];
@ -3103,6 +3114,47 @@ describe('TeamDataService', () => {
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
});
it('attaches runtime advisories during the same snapshot refresh', async () => {
const advisory = {
kind: 'sdk_retrying' as const,
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:01:00.000Z',
retryDelayMs: 60_000,
message: 'capacity retry',
};
const harness = createGetTeamDataHarness({
resolveMembers: () => [buildResolvedMember('alice')],
getMemberAdvisories: async () => new Map([['alice', advisory]]),
});
const data = await harness.service.getTeamData('my-team');
expect(harness.advisoryService.getMemberAdvisories).toHaveBeenCalledTimes(1);
expect(data.members).toEqual([
expect.objectContaining({
name: 'alice',
runtimeAdvisory: advisory,
}),
]);
});
it('degrades advisory lookup failure to warning and still completes the snapshot', async () => {
const harness = createGetTeamDataHarness({
resolveMembers: () => [buildResolvedMember('alice')],
getMemberAdvisories: async () => {
throw new Error('advisory failed');
},
});
const data = await harness.service.getTeamData('my-team');
expect(data.members).toEqual([expect.objectContaining({ name: 'alice' })]);
expect(data.members[0]?.runtimeAdvisory).toBeUndefined();
expect(data.warnings).toEqual(
expect.arrayContaining(['Member runtime advisories failed to load'])
);
});
it('keeps warning order deterministic even when read failures settle out of order', async () => {
const tasksDeferred = createDeferred<TeamTask[]>();
const inboxDeferred = createDeferred<string[]>();

View file

@ -159,7 +159,7 @@ describe('TeamMcpConfigBuilder', () => {
);
});
it('prefers the source MCP entry when workspace source is available', async () => {
it('prefers the built workspace MCP entry when available', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
@ -171,14 +171,7 @@ describe('TeamMcpConfigBuilder', () => {
};
const server = parsed.mcpServers?.['agent-teams'];
expect(server?.command).toBe('pnpm');
expect(server?.args).toEqual([
'--dir',
path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
]);
expectNodeEntry(server, path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
});
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
@ -286,16 +279,7 @@ describe('TeamMcpConfigBuilder', () => {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(parsed.mcpServers['agent-teams']).toMatchObject({
command: 'pnpm',
args: [
'--dir',
path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
],
});
expectNodeEntry(parsed.mcpServers['agent-teams'], path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
});
it('ignores malformed user MCP file', async () => {
@ -500,7 +484,7 @@ describe('TeamMcpConfigBuilder', () => {
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
});
it('packaged mode falls back to workspace source when resourcesPath bundle is missing', async () => {
it('packaged mode falls back to the built workspace MCP entry when resourcesPath bundle is missing', async () => {
setPackagedMode(true, '6.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
@ -510,15 +494,6 @@ describe('TeamMcpConfigBuilder', () => {
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)).toEqual({
command: 'pnpm',
args: [
'--dir',
path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
],
});
expectNodeEntry(readGeneratedServer(configPath), path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
});
});

View file

@ -1,16 +1,74 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '../../../../src/shared/types/team';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function buildMember(
name: string,
removedAt?: number
): Pick<ResolvedTeamMember, 'name' | 'removedAt'> {
return removedAt == null ? { name } : { name, removedAt };
}
function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory {
return {
kind: 'sdk_retrying',
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:01:00.000Z',
retryDelayMs: 60_000,
message: `retry:${label}`,
};
}
function createStubbedServiceHarness() {
const logsFinder = {
findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [
{ filePath: `/logs/${memberName}.jsonl` },
]),
};
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
const readRecentApiRetryAdvisory = vi
.spyOn(service as never, 'readRecentApiRetryAdvisory' as never)
.mockImplementation(async (...args: unknown[]) => {
const filePath = String(args[0] ?? '');
if (advisoryByFilePath.has(filePath)) {
return advisoryByFilePath.get(filePath) ?? null;
}
return buildRetryingAdvisory(path.basename(filePath, '.jsonl'));
});
return { service, logsFinder, advisoryByFilePath, readRecentApiRetryAdvisory };
}
describe('TeamMemberRuntimeAdvisoryService', () => {
let tmpDir: string | null = null;
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
@ -156,4 +214,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
const service = new TeamMemberRuntimeAdvisoryService();
await expect(service.getMemberAdvisory(teamName, 'alice')).resolves.toBeNull();
});
it('reuses batch cache within ttl and returns cloned advisory maps', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
const members = [buildMember('Alice'), buildMember('Bob')];
const first = await service.getMemberAdvisories('signal-ops', members);
const second = await service.getMemberAdvisories('signal-ops', members);
expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(2);
expect(first).toEqual(second);
expect(first).not.toBe(second);
expect(first.get('Alice')).not.toBe(second.get('Alice'));
});
it('shares one in-flight batch request for concurrent identical calls', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
const gate = createDeferred<void>();
logsFinder.findMemberLogs.mockImplementation(async (_teamName: string, memberName: string) => {
await gate.promise;
return [{ filePath: `/logs/${memberName}.jsonl` }];
});
const firstRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]);
const secondRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]);
await Promise.resolve();
expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1);
gate.resolve();
const [first, second] = await Promise.all([firstRequest, secondRequest]);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it('fetches only expired or missing members when building a batch', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
await service.getMemberAdvisory('signal-ops', 'Alice');
const memberCache = (
service as unknown as {
memberCache: Map<string, { value: MemberRuntimeAdvisory | null; expiresAt: number }>;
}
).memberCache;
memberCache.set('signal-ops::bob', {
value: buildRetryingAdvisory('stale-bob'),
expiresAt: Date.now() - 1,
});
const advisories = await service.getMemberAdvisories('signal-ops', [
buildMember('Alice'),
buildMember('Bob'),
buildMember('Charlie'),
]);
expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual([
'Alice',
'Bob',
'Charlie',
]);
expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('caches null advisory batches and avoids repeated lookups within ttl', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
logsFinder.findMemberLogs.mockResolvedValue([]);
const first = await service.getMemberAdvisories('signal-ops', [buildMember('ghost')]);
const second = await service.getMemberAdvisories('signal-ops', [buildMember('ghost')]);
expect(first.size).toBe(0);
expect(second.size).toBe(0);
expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1);
});
it('excludes removed members from batch signature and result', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
const first = await service.getMemberAdvisories('signal-ops', [
buildMember('Alice', Date.now()),
buildMember('Bob'),
]);
const second = await service.getMemberAdvisories('signal-ops', [buildMember('Bob')]);
expect(Array.from(first.keys())).toEqual(['Bob']);
expect(Array.from(second.keys())).toEqual(['Bob']);
expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1);
expect(logsFinder.findMemberLogs).toHaveBeenCalledWith('signal-ops', 'Bob', expect.any(Number));
});
it('invalidates team batch cache when member set changes', async () => {
const { service, logsFinder } = createStubbedServiceHarness();
const first = await service.getMemberAdvisories('signal-ops', [buildMember('Alice')]);
const second = await service.getMemberAdvisories('signal-ops', [
buildMember('Alice'),
buildMember('Bob'),
]);
expect(Array.from(first.keys())).toEqual(['Alice']);
expect(Array.from(second.keys())).toEqual(['Alice', 'Bob']);
expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual(['Alice', 'Bob']);
});
});

View file

@ -164,6 +164,7 @@ describe('TeamProvisioningService', () => {
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
await expect(
@ -228,6 +229,7 @@ describe('TeamProvisioningService', () => {
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = restorePrelaunchConfig;
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
@ -277,6 +279,7 @@ describe('TeamProvisioningService', () => {
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).stopFilesystemMonitor = vi.fn();
@ -358,6 +361,7 @@ describe('TeamProvisioningService', () => {
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
await expect(

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
shouldWarnOnMissingRegisteredMember,
shouldWarnOnUnreadableMemberAuditConfig,
} from '@main/services/team/TeamProvisioningService';
describe('TeamProvisioningService audit warning policy', () => {
it('suppresses unreadable config warnings during the short post-accept grace window', () => {
const nowMs = Date.parse('2026-04-09T12:01:00.000Z');
const memberSpawnStatuses = new Map([
[
'alice',
{
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-04-09T12:00:30.000Z',
},
],
]);
expect(
shouldWarnOnUnreadableMemberAuditConfig({
nowMs,
lastWarnAt: 0,
expectedMembers: ['alice'],
memberSpawnStatuses,
})
).toBe(false);
});
it('warns on unreadable config only after a teammate has exceeded the launch grace window', () => {
const nowMs = Date.parse('2026-04-09T12:02:00.000Z');
const memberSpawnStatuses = new Map([
[
'alice',
{
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-04-09T12:00:00.000Z',
},
],
]);
expect(
shouldWarnOnUnreadableMemberAuditConfig({
nowMs,
lastWarnAt: 0,
expectedMembers: ['alice'],
memberSpawnStatuses,
})
).toBe(true);
});
it('only warns about missing registered members after grace expiry', () => {
const nowMs = Date.parse('2026-04-09T12:02:00.000Z');
expect(
shouldWarnOnMissingRegisteredMember({
nowMs,
lastWarnAt: 0,
graceExpired: false,
})
).toBe(false);
expect(
shouldWarnOnMissingRegisteredMember({
nowMs,
lastWarnAt: 0,
graceExpired: true,
})
).toBe(true);
});
});

View file

@ -80,6 +80,8 @@ async function setupRunningTeam(teamName: string) {
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer' }],
source: 'config-fallback',

View file

@ -54,17 +54,34 @@ function createFakeChild() {
return { child, writeSpy };
}
function extractPromptFromWrite(writeSpy: ReturnType<typeof vi.fn>): string {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
const parsed = JSON.parse(payload) as {
type: string;
message?: { role: string; content: { type: string; text?: string }[] };
};
const text = parsed.message?.content?.[0]?.text;
if (typeof text !== 'string') {
throw new Error('Failed to extract prompt text from stdin write payload');
function extractPromptFromBootstrapFile(callIndex = 0): string {
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
const promptFlagIndex = args?.indexOf('--team-bootstrap-user-prompt-file') ?? -1;
const promptPath = promptFlagIndex >= 0 ? args?.[promptFlagIndex + 1] : null;
if (!promptPath) {
throw new Error('Failed to extract bootstrap prompt file path from spawn args');
}
return text;
return fs.readFileSync(promptPath, 'utf8');
}
function extractBootstrapSpec(callIndex = 0): {
mode?: string;
team?: { name?: string; cwd?: string };
lead?: { permissionSeedTools?: string[] };
members?: Array<Record<string, unknown>>;
} {
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
const specFlagIndex = args?.indexOf('--team-bootstrap-spec') ?? -1;
const specPath = specFlagIndex >= 0 ? args?.[specFlagIndex + 1] : null;
if (!specPath) {
throw new Error('Failed to extract bootstrap spec path from spawn args');
}
return JSON.parse(fs.readFileSync(specPath, 'utf8')) as {
mode?: string;
team?: { name?: string; cwd?: string };
lead?: { permissionSeedTools?: string[] };
members?: Array<Record<string, unknown>>;
};
}
describe('TeamProvisioningService prompt content (solo mode discipline)', () => {
@ -88,7 +105,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
}
});
it('createTeam prompt (solo) mandates sequential status + frequent user updates', async () => {
it('createTeam uses deterministic bootstrap spec and safe flags in solo mode', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child, writeSpy } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
@ -98,6 +115,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
@ -111,31 +129,19 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
() => {}
);
expect(writeSpy).toHaveBeenCalledTimes(1);
const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('PROGRESS REPORTING (MANDATORY)');
expect(prompt).toContain('Never bulk-move many tasks at the end');
expect(prompt).toContain('Default to working ONE task at a time');
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete');
expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('ASK: Strict read-only conversation mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain(
'Built-in Agent usage rule: the built-in Agent tool is allowed only for normal Claude Code-style subagents WITHOUT team_name, and only on turns whose action mode is DO.'
);
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).not.toContain('teamctl.js');
expect(prompt).not.toContain('.claude/tools');
expect(writeSpy).not.toHaveBeenCalled();
const bootstrapSpec = extractBootstrapSpec();
expect(bootstrapSpec.mode).toBe('create');
expect(bootstrapSpec.team).toMatchObject({
name: 'solo-team',
cwd: process.cwd(),
});
expect(bootstrapSpec.members).toEqual([]);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--mcp-config');
expect(launchArgs).toContain('--team-bootstrap-spec');
expect(launchArgs).not.toContain('--team-bootstrap-user-prompt-file');
expect(launchArgs).not.toContain('--strict-mcp-config');
expect(launchArgs).toContain('--disallowedTools');
const disallowed = launchArgs[launchArgs.indexOf('--disallowedTools') + 1] ?? '';
@ -145,7 +151,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
it('launchTeam prompt (solo) requires sequential execution and incremental updates', async () => {
it('launchTeam prompt (solo) uses deterministic refresh-only reconnect instructions', async () => {
// Seed config.json so launchTeam can validate team existence.
const teamName = 'solo-team-launch';
const teamDir = path.join(tempTeamsBase, teamName);
@ -172,11 +178,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [],
source: 'config-fallback',
warning: undefined,
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
@ -189,17 +197,16 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
() => {}
);
expect(writeSpy).toHaveBeenCalledTimes(1);
const prompt = extractPromptFromWrite(writeSpy);
expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated');
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
expect(prompt).toContain('Do NOT delay this reconnect turn by reading internal config files');
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
expect(prompt).toContain('Do NOT start implementation in this turn.');
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.');
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).not.toContain('teamctl.js');
@ -212,7 +219,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
it('createTeam prompt for teammates includes explicit hidden-instruction block rules', async () => {
it('createTeam bootstrap spec carries teammate descriptors for deterministic startup', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child, writeSpy } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
@ -222,6 +229,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
@ -235,42 +243,38 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
() => {}
);
const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):');
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('DO: Full execution mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain(
'Built-in Agent usage rule: the built-in Agent tool is allowed only for normal Claude Code-style subagents WITHOUT team_name, and only on turns whose action mode is DO.'
);
expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
expect(prompt).toContain('If member_briefing fails, send a short message to your team lead');
expect(prompt).toContain('Introduce yourself briefly (name and role) and confirm you are ready');
expect(prompt).toContain('use task_briefing as your compact queue view');
expect(prompt).toContain('Use task_get when you need the full task context before starting a pending/needsFix task');
expect(prompt).toContain(
'If that teammate already has another in_progress task, create/keep the new task in pending/TODO. Do NOT mark it in_progress for them yet.'
);
expect(prompt).toContain(
'leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO'
);
expect(prompt).toContain(
'Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.'
);
expect(prompt).toContain(
'do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention.'
);
expect(prompt).not.toContain('Include the following agent-only instructions verbatim in the prompt:');
expect(prompt).not.toContain('runtime forwards task comments to the lead automatically');
expect(writeSpy).not.toHaveBeenCalled();
const bootstrapSpec = extractBootstrapSpec();
expect(bootstrapSpec.mode).toBe('create');
expect(bootstrapSpec.members).toEqual([
expect.objectContaining({
name: 'alice',
role: 'developer',
description: 'developer',
cwd: process.cwd(),
}),
]);
await svc.cancelProvisioning(runId);
});
it('includes task-comment forwarding wording by default', async () => {
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
const teamName = 'forward-live-team';
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
description: 'Task comment forwarding live prompt test',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
],
}),
'utf8'
);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child, writeSpy } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
@ -280,22 +284,33 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer' }],
source: 'config-fallback',
warning: undefined,
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
const { runId } = await svc.createTeam(
const { runId } = await svc.launchTeam(
{
teamName: 'forward-live-team',
teamName,
cwd: process.cwd(),
members: [{ name: 'alice', role: 'developer' }],
description: 'Task comment forwarding live prompt test',
clearContext: true,
},
() => {}
);
const prompt = extractPromptFromWrite(writeSpy);
expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain(
'do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention.'
'Teammate task comments are auto-forwarded to you.'
);
await svc.cancelProvisioning(runId);
@ -331,11 +346,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer' }],
source: 'config-fallback',
warning: undefined,
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
@ -348,27 +365,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
() => {}
);
const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('The team has been reconnected after a restart.');
expect(prompt).toContain('Restore/start the existing teammates first.');
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):');
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
expect(prompt).toContain('If member_briefing fails, send a short message to your team lead');
expect(prompt).toContain('After member_briefing succeeds:');
expect(prompt).toContain('Use task_briefing as your compact queue view.');
expect(prompt).toContain('resume/finish those first');
expect(prompt).toContain('Call task_get only if you need more context than task_briefing already gave you');
expect(prompt).toContain('Before you start any needsFix or pending task, call task_get');
expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.');
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.');
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('Messages to "user" (the human) must NEVER contain agent-only blocks.');
expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain('task_set_owner');
expect(prompt).toContain('cross_team_send');
expect(prompt).toContain(
'If you assign a task to a member who already has another in_progress task, keep the newly assigned task pending/TODO. Do NOT move it to in_progress until that member actually starts it.'
);
expect(prompt).toContain(
'leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO'
'review_request already notifies the reviewer'
);
await svc.cancelProvisioning(runId);

View file

@ -0,0 +1,230 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { mockYieldToEventLoop } = vi.hoisted(() => ({
mockYieldToEventLoop: vi.fn<() => Promise<void>>(),
}));
vi.mock('@main/utils/asyncYield', () => ({
yieldToEventLoop: mockYieldToEventLoop,
}));
import {
createTeamReconcileDrainScheduler,
type TeamReconcileTrigger,
} from '../../../../src/main/services/team/TeamReconcileDrainScheduler';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe('TeamReconcileDrainScheduler', () => {
afterEach(() => {
vi.restoreAllMocks();
mockYieldToEventLoop.mockReset();
});
it('runs exactly one pass for a single scheduled event', async () => {
mockYieldToEventLoop.mockResolvedValue(undefined);
const calls: Array<{ teamName: string; trigger: TeamReconcileTrigger }> = [];
const scheduler = createTeamReconcileDrainScheduler({
run: vi.fn(async (teamName, trigger) => {
calls.push({ teamName, trigger });
}),
});
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
await flushAsyncWork();
expect(calls).toEqual([
{
teamName: 'team-a',
trigger: { source: 'inbox', detail: 'inboxes/alice.json' },
},
]);
scheduler.dispose();
});
it('collapses a burst for the same team into a trailing pass with the latest trigger', async () => {
mockYieldToEventLoop.mockResolvedValue(undefined);
const firstPass = createDeferred<void>();
const calls: TeamReconcileTrigger[] = [];
const scheduler = createTeamReconcileDrainScheduler({
run: vi.fn(async (_teamName, trigger) => {
calls.push(trigger);
if (calls.length === 1) {
await firstPass.promise;
}
}),
});
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
await flushAsyncWork();
expect(calls).toEqual([{ source: 'inbox', detail: 'inboxes/alice.json' }]);
scheduler.schedule('team-a', { source: 'task', detail: 'task-1.json' });
scheduler.schedule('team-a', { source: 'task', detail: 'task-2.json' });
await flushAsyncWork();
expect(calls).toHaveLength(1);
firstPass.resolve(undefined as void);
await flushAsyncWork();
expect(calls).toEqual([
{ source: 'inbox', detail: 'inboxes/alice.json' },
{ source: 'task', detail: 'task-2.json' },
]);
scheduler.dispose();
});
it('does not lose a new event that arrives while the scheduler is yielding back to the event loop', async () => {
const yieldGate = createDeferred<void>();
mockYieldToEventLoop.mockImplementationOnce(() => yieldGate.promise).mockResolvedValue(undefined);
const calls: TeamReconcileTrigger[] = [];
const scheduler = createTeamReconcileDrainScheduler({
run: vi.fn(async (_teamName, trigger) => {
calls.push(trigger);
}),
});
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
await flushAsyncWork();
expect(calls).toEqual([{ source: 'inbox', detail: 'inboxes/alice.json' }]);
scheduler.schedule('team-a', { source: 'task', detail: 'task-3.json' });
await flushAsyncWork();
expect(calls).toHaveLength(1);
yieldGate.resolve(undefined as void);
await flushAsyncWork();
expect(calls).toEqual([
{ source: 'inbox', detail: 'inboxes/alice.json' },
{ source: 'task', detail: 'task-3.json' },
]);
scheduler.dispose();
});
it('runs different teams independently', async () => {
mockYieldToEventLoop.mockResolvedValue(undefined);
const teamADeferred = createDeferred<void>();
const teamBDeferred = createDeferred<void>();
const calls: Array<{ teamName: string; trigger: TeamReconcileTrigger }> = [];
const scheduler = createTeamReconcileDrainScheduler({
run: vi.fn(async (teamName, trigger) => {
calls.push({ teamName, trigger });
if (teamName === 'team-a') {
await teamADeferred.promise;
return;
}
await teamBDeferred.promise;
}),
});
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/a.json' });
scheduler.schedule('team-b', { source: 'task', detail: 'task-b.json' });
await flushAsyncWork();
expect(calls).toEqual([
{ teamName: 'team-a', trigger: { source: 'inbox', detail: 'inboxes/a.json' } },
{ teamName: 'team-b', trigger: { source: 'task', detail: 'task-b.json' } },
]);
teamADeferred.resolve(undefined as void);
teamBDeferred.resolve(undefined as void);
await flushAsyncWork();
scheduler.dispose();
});
it('does not wedge scheduler state after a failed run', async () => {
mockYieldToEventLoop.mockResolvedValue(undefined);
const run = vi
.fn<(teamName: string, trigger: TeamReconcileTrigger) => Promise<void>>()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined);
const scheduler = createTeamReconcileDrainScheduler({ run });
scheduler.schedule('team-a', { source: 'task', detail: 'task-1.json' });
await flushAsyncWork();
expect(run).toHaveBeenCalledTimes(1);
scheduler.schedule('team-a', { source: 'task', detail: 'task-2.json' });
await flushAsyncWork();
expect(run).toHaveBeenCalledTimes(2);
scheduler.dispose();
});
it('does not lose a new event that arrives while a failed pass is yielding', async () => {
const yieldGate = createDeferred<void>();
mockYieldToEventLoop.mockImplementationOnce(() => yieldGate.promise).mockResolvedValue(undefined);
const run = vi
.fn<(teamName: string, trigger: TeamReconcileTrigger) => Promise<void>>()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined);
const scheduler = createTeamReconcileDrainScheduler({ run });
scheduler.schedule('team-a', { source: 'task', detail: 'task-1.json' });
await flushAsyncWork();
expect(run).toHaveBeenCalledTimes(1);
scheduler.schedule('team-a', { source: 'task', detail: 'task-2.json' });
await flushAsyncWork();
expect(run).toHaveBeenCalledTimes(1);
yieldGate.resolve(undefined as void);
await flushAsyncWork();
expect(run).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenNthCalledWith(2, 'team-a', {
source: 'task',
detail: 'task-2.json',
});
scheduler.dispose();
});
it('stops accepting future schedules after dispose without interrupting an active run', async () => {
mockYieldToEventLoop.mockResolvedValue(undefined);
const firstPass = createDeferred<void>();
const calls: TeamReconcileTrigger[] = [];
const scheduler = createTeamReconcileDrainScheduler({
run: vi.fn(async (_teamName, trigger) => {
calls.push(trigger);
await firstPass.promise;
}),
});
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
await flushAsyncWork();
expect(calls).toEqual([{ source: 'inbox', detail: 'inboxes/alice.json' }]);
scheduler.dispose();
scheduler.schedule('team-a', { source: 'task', detail: 'task-9.json' });
firstPass.resolve(undefined as void);
await flushAsyncWork();
expect(calls).toEqual([{ source: 'inbox', detail: 'inboxes/alice.json' }]);
});
});

View file

@ -0,0 +1,278 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const storeState = {
progress: null as Record<string, unknown> | null,
cancelProvisioning: vi.fn(),
selectedTeamName: 'northstar-core',
selectedTeamData: {
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined },
{ name: 'bob', agentType: 'developer' },
{ name: 'jack', agentType: 'developer' },
] as Array<Record<string, unknown>>,
},
memberSpawnStatusesByTeam: {
'northstar-core': {},
},
memberSpawnSnapshotsByTeam: {} as Record<string, unknown>,
};
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress,
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) =>
React.createElement('button', { type: 'button' }, children),
}));
vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({
ProvisioningProgressBlock: ({
currentStepIndex,
successMessage,
successMessageSeverity,
}: {
currentStepIndex: number;
successMessage?: string | null;
successMessageSeverity?: string;
}) =>
React.createElement(
'div',
{
'data-testid': 'progress-block',
'data-current-step-index': String(currentStepIndex),
'data-success-severity': successMessageSeverity ?? '',
},
successMessage ?? ''
),
}));
import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner';
describe('TeamProvisioningBanner launch-step alignment', () => {
afterEach(() => {
document.body.innerHTML = '';
});
beforeEach(() => {
storeState.selectedTeamName = 'northstar-core';
storeState.progress = {
runId: 'run-1',
teamName: 'northstar-core',
state: 'ready',
startedAt: '2026-04-08T16:00:00.000Z',
message: 'Launch completed',
messageSeverity: undefined,
pid: 1234,
cliLogsTail: '',
assistantOutput: '',
};
storeState.memberSpawnStatusesByTeam['northstar-core'] = {};
storeState.selectedTeamData.members = [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined },
{ name: 'bob', agentType: 'developer', runtimeAdvisory: undefined },
{ name: 'jack', agentType: 'developer', runtimeAdvisory: undefined },
];
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 0,
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
source: 'merged',
};
});
it('keeps Members joining as the active step while teammates are still starting after ready', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
await Promise.resolve();
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('2');
expect(block?.textContent).toContain('0/3 teammates made contact');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows all steps complete only after teammates actually made contact', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 3,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
source: 'merged',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
await Promise.resolve();
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('4');
expect(block?.textContent).toContain('all 3 teammates made contact');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps warning severity while runtimes are online but teammate contact is still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 0,
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 3,
},
source: 'merged',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
await Promise.resolve();
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('2');
expect(block?.getAttribute('data-success-severity')).toBe('warning');
expect(block?.textContent).toContain('teammates online');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('surfaces provider retry wording when pending runtimes are retrying provider capacity', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.selectedTeamData.members = [
{ name: 'team-lead', agentType: 'team-lead' },
{
name: 'alice',
agentType: 'reviewer',
runtimeAdvisory: {
kind: 'sdk_retrying',
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:00:45.000Z',
retryDelayMs: 45_000,
},
},
{
name: 'bob',
agentType: 'developer',
runtimeAdvisory: {
kind: 'sdk_retrying',
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:00:45.000Z',
retryDelayMs: 45_000,
},
},
{
name: 'jack',
agentType: 'developer',
runtimeAdvisory: {
kind: 'sdk_retrying',
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:00:45.000Z',
retryDelayMs: 45_000,
},
},
];
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
},
bob: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
},
jack: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
},
};
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 1,
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 3,
},
source: 'merged',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
await Promise.resolve();
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.textContent).toContain('retrying provider capacity');
expect(block?.getAttribute('data-success-severity')).toBe('warning');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,124 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) => React.createElement('span', { className, title }, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
}));
import { MemberCard } from '@renderer/components/team/members/MemberCard';
const member: ResolvedTeamMember = {
name: 'alice',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
removedAt: undefined,
};
describe('MemberCard starting-state visuals', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('shows starting skeleton treatment even after provisioning is no longer active', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'Anthropic · haiku · Medium',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'spawning',
spawnLaunchState: 'starting',
spawnRuntimeAlive: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('starting');
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThan(0);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows provider retry advisory instead of plain online while bootstrap contact is still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
runtimeAdvisory: {
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2099-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
},
},
memberColor: 'blue',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('retrying now');
expect(host.textContent).not.toContain('online');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,71 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogDescription: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/members/MemberRoleEditor', () => ({
MemberRoleEditor: () => null,
}));
import { MemberDetailHeader } from '@renderer/components/team/members/MemberDetailHeader';
const member: ResolvedTeamMember = {
name: 'alice',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
removedAt: undefined,
};
describe('MemberDetailHeader spawn-aware presence', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('shows starting from spawn props even when coarse team state would read as idle', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailHeader, {
member,
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'spawning',
spawnLaunchState: 'starting',
spawnRuntimeAlive: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('idle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,113 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
const member: ResolvedTeamMember = {
name: 'alice',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
removedAt: undefined,
};
const storeState = {
selectedTeamData: {
members: [member],
isAlive: true,
tasks: [],
},
selectedTeamName: 'northstar-core',
memberSpawnStatusesByTeam: {
'northstar-core': {
alice: {
status: 'spawning',
launchState: 'starting',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
},
},
},
leadActivityByTeam: {},
openMemberProfile: vi.fn(),
};
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
HoverCardTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
HoverCardContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
}));
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
describe('MemberHoverCard spawn-aware presence', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
beforeEach(() => {
storeState.selectedTeamData.members = [member];
storeState.selectedTeamData.isAlive = true;
storeState.selectedTeamData.tasks = [];
storeState.selectedTeamName = 'northstar-core';
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'spawning',
launchState: 'starting',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
};
storeState.openMemberProfile.mockReset();
});
it('shows starting from the team spawn snapshot even when provisioning is no longer active', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberHoverCard, {
name: 'alice',
children: React.createElement('button', { type: 'button' }, 'alice'),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('idle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { shouldSuppressMissingLeadSessionFetch } from '@renderer/components/team/teamSessionFetchGuards';
describe('teamSessionFetchGuards', () => {
it('suppresses repeated silent fetches for the same missing lead session id', () => {
expect(
shouldSuppressMissingLeadSessionFetch({
leadSessionId: 'missing-session',
projectId: 'project-1',
sessionsLoading: false,
knownSessions: [{ id: 'other-session' }],
suppressionKey: 'team:project-1:missing-session:history-a',
currentKey: 'team:project-1:missing-session:history-a',
})
).toBe(true);
});
it('allows a fresh fetch when the lead session id changes', () => {
expect(
shouldSuppressMissingLeadSessionFetch({
leadSessionId: 'new-session',
projectId: 'project-1',
sessionsLoading: false,
knownSessions: [{ id: 'other-session' }],
suppressionKey: 'team:project-1:missing-session:history-a',
currentKey: 'team:project-1:new-session:history-a',
})
).toBe(false);
});
it('does not suppress while session inventory is still loading', () => {
expect(
shouldSuppressMissingLeadSessionFetch({
leadSessionId: 'missing-session',
projectId: 'project-1',
sessionsLoading: true,
knownSessions: [{ id: 'other-session' }],
suppressionKey: 'team:project-1:missing-session:history-a',
currentKey: 'team:project-1:missing-session:history-a',
})
).toBe(false);
});
});

View file

@ -0,0 +1,81 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) =>
React.createElement('button', { type: 'button' }, children),
}));
vi.mock('@renderer/features/agent-graph/ui/GraphTaskCard', () => ({
GraphTaskCard: () => React.createElement('div', null, 'task-card'),
}));
import { GraphNodePopover } from '@renderer/features/agent-graph/ui/GraphNodePopover';
import type { GraphNode } from '@claude-teams/agent-graph';
function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): GraphNode {
return {
id: 'member:alice',
kind: 'member',
label: 'alice',
role: 'Reviewer',
state: 'idle',
color: '#60a5fa',
avatarUrl: undefined,
domainRef: { kind: 'member', teamName: 'northstar-core', memberName: 'alice' },
spawnStatus,
currentTaskId: undefined,
currentTaskSubject: undefined,
activeTool: undefined,
} as GraphNode;
}
describe('GraphNodePopover spawn badge labels', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('shows human-facing starting for raw waiting/spawning spawn statuses', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
React.Fragment,
null,
React.createElement(GraphNodePopover, {
node: makeMemberNode('waiting'),
teamName: 'northstar-core',
onClose: vi.fn(),
}),
React.createElement(GraphNodePopover, {
node: makeMemberNode('spawning'),
teamName: 'northstar-core',
onClose: vi.fn(),
})
)
);
await Promise.resolve();
});
expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('waiting');
expect(host.textContent).not.toContain('spawning');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -1,6 +1,8 @@
import {
getLaunchAwarePresenceLabel,
getSpawnAwareDotClass,
getSpawnAwarePresenceLabel,
getSpawnCardClass,
getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle,
} from '@renderer/utils/memberHelpers';
@ -41,6 +43,7 @@ describe('memberHelpers spawn-aware presence', () => {
'online',
'runtime_pending_bootstrap',
true,
true,
false,
undefined
)
@ -56,12 +59,62 @@ describe('memberHelpers spawn-aware presence', () => {
undefined,
false,
true,
true,
false,
undefined
)
).toBe('starting');
});
it('keeps starting visuals after provisioning already transitioned out of active state', () => {
expect(
getSpawnAwarePresenceLabel(
member,
'spawning',
'starting',
undefined,
false,
true,
false,
undefined
)
).toBe('starting');
expect(getSpawnAwareDotClass(member, 'spawning', 'starting', false, true, false, undefined)).toContain(
'bg-amber-400'
);
expect(getSpawnCardClass('spawning', 'starting', false)).toContain('member-waiting-shimmer');
});
it('shows offline instead of stale starting visuals when the team is offline', () => {
expect(
getSpawnAwarePresenceLabel(
member,
'spawning',
'starting',
undefined,
false,
false,
false,
undefined
)
).toBe('offline');
expect(
getSpawnAwareDotClass(
member,
'spawning',
'starting',
false,
false,
false,
undefined
)
).toContain('bg-red-400');
expect(getSpawnCardClass('spawning', 'starting', false, false, false)).toBe('opacity-40');
});
it('renders unified retry advisory labels for provider retries', () => {
expect(
getMemberRuntimeAdvisoryLabel(
@ -86,4 +139,46 @@ describe('memberHelpers spawn-aware presence', () => {
})
).toContain('capacity exceeded');
});
it('surfaces retry advisory text instead of plain online while bootstrap contact is still pending', () => {
expect(
getLaunchAwarePresenceLabel(
member,
'online',
'runtime_pending_bootstrap',
'process',
true,
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2099-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
message: 'Gemini cli backend error: capacity exceeded.',
},
true,
false,
undefined
)
).toContain('retrying now');
expect(
getLaunchAwarePresenceLabel(
member,
'online',
'runtime_pending_bootstrap',
'process',
false,
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2099-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
message: 'Gemini cli backend error: capacity exceeded.',
},
true,
false,
undefined
)
).toBe('online');
});
});