fix(team-runtime): harden refresh flows and reduce ui churn
This commit is contained in:
parent
535178a076
commit
17bd573ce3
52 changed files with 4495 additions and 439 deletions
1261
docs/research/get-team-data-parallel-read-plan.md
Normal file
1261
docs/research/get-team-data-parallel-read-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
84
scripts/dev-with-runtime.mjs
Normal file
84
scripts/dev-with-runtime.mjs
Normal 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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
93
src/main/services/team/TeamReconcileDrainScheduler.ts
Normal file
93
src/main/services/team/TeamReconcileDrainScheduler.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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'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'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 ?? '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ===
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
26
src/renderer/components/team/teamSessionFetchGuards.ts
Normal file
26
src/renderer/components/team/teamSessionFetchGuards.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
230
test/main/services/team/TeamReconcileDrainScheduler.test.ts
Normal file
230
test/main/services/team/TeamReconcileDrainScheduler.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
278
test/renderer/components/team/TeamProvisioningBanner.test.ts
Normal file
278
test/renderer/components/team/TeamProvisioningBanner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
124
test/renderer/components/team/members/MemberCard.test.ts
Normal file
124
test/renderer/components/team/members/MemberCard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
113
test/renderer/components/team/members/MemberHoverCard.test.ts
Normal file
113
test/renderer/components/team/members/MemberHoverCard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
44
test/renderer/components/team/teamSessionFetchGuards.test.ts
Normal file
44
test/renderer/components/team/teamSessionFetchGuards.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
test/renderer/features/agent-graph/GraphNodePopover.test.ts
Normal file
81
test/renderer/features/agent-graph/GraphNodePopover.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue