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",
|
"main": "dist-electron/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||||
|
"dev:ui": "electron-vite dev",
|
||||||
"dev:kill": "node bin/kill-dev.js",
|
"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",
|
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||||
"build": "electron-vite 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';
|
} from './services/team/TeamControlApiState';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
||||||
|
import {
|
||||||
|
createTeamReconcileDrainScheduler,
|
||||||
|
type TeamReconcileTrigger,
|
||||||
|
} from './services/team/TeamReconcileDrainScheduler';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
|
|
@ -510,6 +514,27 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
context.fileWatcher.on('todo-change', todoChangeHandler);
|
context.fileWatcher.on('todo-change', todoChangeHandler);
|
||||||
todoChangeCleanup = () => context.fileWatcher.off('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
|
// Forward team-change events to renderer and HTTP SSE
|
||||||
const teamChangeHandler = (event: unknown): void => {
|
const teamChangeHandler = (event: unknown): void => {
|
||||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||||
|
|
@ -525,12 +550,8 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
|
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
if (teamDataService) {
|
if (reconcileScheduler) {
|
||||||
void teamDataService
|
reconcileScheduler.schedule(teamName, { source: 'inbox', detail });
|
||||||
.reconcileTeamArtifacts(teamName)
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay inbox changes into active runtime recipients.
|
// 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 ---
|
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||||
void teamDataService
|
reconcileScheduler?.schedule(teamName, { source: 'task', detail });
|
||||||
.reconcileTeamArtifacts(teamName)
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
const taskId = detail.replace('.json', '');
|
const taskId = detail.replace('.json', '');
|
||||||
void teamDataService
|
void teamDataService
|
||||||
|
|
@ -625,7 +642,10 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
context.fileWatcher.on('team-change', teamChangeHandler);
|
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}`);
|
logger.info(`FileWatcher events wired for context: ${context.id}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ import {
|
||||||
TEAM_VALIDATE_CLI_ARGS,
|
TEAM_VALIDATE_CLI_ARGS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} 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 { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||||
|
|
@ -256,6 +256,20 @@ function buildLeadRosterContextBlock(
|
||||||
].join('\n');
|
].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.
|
* In-memory set of API error message keys already processed.
|
||||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||||
|
|
@ -1676,6 +1690,7 @@ async function handleSendMessage(
|
||||||
const resolvedLeadName = leadName ?? memberName;
|
const resolvedLeadName = leadName ?? memberName;
|
||||||
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
||||||
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
||||||
|
const delegateAckBlock = buildLeadDirectDelegateAckBlock(actionMode);
|
||||||
// Pre-generate stable messageId so both stdin and persistence use the same identity.
|
// 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.
|
// This allows the lead to call task_create_from_message with the exact messageId.
|
||||||
const preGeneratedMessageId = crypto.randomUUID();
|
const preGeneratedMessageId = crypto.randomUUID();
|
||||||
|
|
@ -1694,6 +1709,7 @@ async function handleSendMessage(
|
||||||
`You received a direct message from the user.`,
|
`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.`,
|
`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] : []),
|
...(rosterContextBlock ? [rosterContextBlock] : []),
|
||||||
|
...(delegateAckBlock ? [delegateAckBlock] : []),
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
`MessageId: ${preGeneratedMessageId}`,
|
`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.`,
|
`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 type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
import { configManager } from '../infrastructure/ConfigManager';
|
import { configManager } from '../infrastructure/ConfigManager';
|
||||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||||
import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv';
|
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
|
|
@ -174,21 +174,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
||||||
const env = { ...this.buildCliEnv(binaryPath) };
|
return applyProviderRuntimeEnv({ ...this.buildCliEnv(binaryPath) }, providerId);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
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_ENTRY_PROVIDER',
|
||||||
'CLAUDE_CODE_USE_OPENAI',
|
'CLAUDE_CODE_USE_OPENAI',
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
|
|
@ -36,15 +37,17 @@ export function applyProviderRuntimeEnv(
|
||||||
const resolvedProvider: TeamProviderId =
|
const resolvedProvider: TeamProviderId =
|
||||||
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
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;
|
env[key] = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedProvider === 'codex') {
|
// Provider overrides must be positive pins. In dev and multimodel desktop
|
||||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
|
// flows the host process can already be routed to codex or gemini, and the
|
||||||
} else if (resolvedProvider === 'gemini') {
|
// child runtime reapplies settings.env after trust. Mark the env as
|
||||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
|
// 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;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,21 +176,6 @@ async function resolveFromCandidateList(candidates: string[]): Promise<string |
|
||||||
return null;
|
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;
|
let cachedPath: string | null | undefined;
|
||||||
|
|
||||||
/** Timestamp of last successful cache verification (ms). */
|
/** Timestamp of last successful cache verification (ms). */
|
||||||
|
|
@ -257,9 +242,13 @@ export class ClaudeBinaryResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flavor === 'free-code') {
|
if (flavor === 'free-code') {
|
||||||
const repoLocalCli = await resolveFromCandidateList(getRepoLocalCliCandidates());
|
// Keep free-code resolution generic. Dev flows should inject an explicit
|
||||||
if (repoLocalCli) {
|
// CLAUDE_CLI_PATH, while non-dev setups can expose claude-multimodel on
|
||||||
cachedPath = repoLocalCli;
|
// 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();
|
cacheVerifiedAt = Date.now();
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,18 @@ interface TaskChangeLogSourceSnapshot {
|
||||||
logSourceGeneration: string | null;
|
logSourceGeneration: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileWatchReconcileDiagnostics {
|
||||||
|
inFlight: number;
|
||||||
|
burstCount: number;
|
||||||
|
windowStartedAt: number;
|
||||||
|
lastPressureLogAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileWatchReconcileTrigger {
|
||||||
|
source: 'inbox' | 'task';
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamDataService {
|
export class TeamDataService {
|
||||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processHealthTeams = new Set<string>();
|
private processHealthTeams = new Set<string>();
|
||||||
|
|
@ -121,6 +133,7 @@ export class TeamDataService {
|
||||||
private taskCommentNotificationInFlight = new Set<string>();
|
private taskCommentNotificationInFlight = new Set<string>();
|
||||||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
|
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -509,6 +522,11 @@ export class TeamDataService {
|
||||||
const t = marks[label];
|
const t = marks[label];
|
||||||
return typeof t === 'number' ? t - startedAt : -1;
|
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);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|
@ -688,6 +706,7 @@ export class TeamDataService {
|
||||||
let messages: InboxMessage[] = messagesStepResult.value;
|
let messages: InboxMessage[] = messagesStepResult.value;
|
||||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
||||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
||||||
|
mark('postStart');
|
||||||
|
|
||||||
if (leadTexts.length > 0) {
|
if (leadTexts.length > 0) {
|
||||||
messages = [...messages, ...leadTexts];
|
messages = [...messages, ...leadTexts];
|
||||||
|
|
@ -695,6 +714,7 @@ export class TeamDataService {
|
||||||
if (sentMessages.length > 0) {
|
if (sentMessages.length > 0) {
|
||||||
messages = [...messages, ...sentMessages];
|
messages = [...messages, ...sentMessages];
|
||||||
}
|
}
|
||||||
|
mark('mergeMessages');
|
||||||
|
|
||||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
// 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.
|
// 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);
|
return !leadSessionFingerprints.has(fp);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
mark('dedupLeadTexts');
|
||||||
|
|
||||||
// Dedup exact message copies that can appear as both live lead_process rows and
|
// 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,
|
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
||||||
|
|
@ -779,6 +800,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
||||||
}
|
}
|
||||||
|
mark('dedupMessageIds');
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
// 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));
|
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
this.annotateSlashCommandResponses(messages);
|
this.annotateSlashCommandResponses(messages);
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
mark('normalizeMessages');
|
||||||
|
|
||||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||||
|
|
@ -840,8 +864,10 @@ export class TeamDataService {
|
||||||
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
|
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||||
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
|
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
|
||||||
);
|
);
|
||||||
|
mark('attachKanban');
|
||||||
|
|
||||||
const presenceIndex = await presenceIndexPromise;
|
const presenceIndex = await presenceIndexPromise;
|
||||||
|
mark('loadPresenceIndex');
|
||||||
|
|
||||||
const taskChangePresenceById = this.resolveTaskChangePresenceMap(
|
const taskChangePresenceById = this.resolveTaskChangePresenceMap(
|
||||||
tasksWithKanbanBase,
|
tasksWithKanbanBase,
|
||||||
|
|
@ -855,6 +881,7 @@ export class TeamDataService {
|
||||||
changePresence: taskChangePresenceById[task.id] ?? 'unknown',
|
changePresence: taskChangePresenceById[task.id] ?? 'unknown',
|
||||||
}))
|
}))
|
||||||
: tasksWithKanbanBase;
|
: tasksWithKanbanBase;
|
||||||
|
mark('changePresence');
|
||||||
|
|
||||||
const members = this.memberResolver.resolveMembers(
|
const members = this.memberResolver.resolveMembers(
|
||||||
config,
|
config,
|
||||||
|
|
@ -897,18 +924,45 @@ export class TeamDataService {
|
||||||
|
|
||||||
const totalMs = Date.now() - startedAt;
|
const totalMs = Date.now() - startedAt;
|
||||||
if (totalMs >= 1500) {
|
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(
|
logger.warn(
|
||||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||||
'inboxNames'
|
'inboxNames'
|
||||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||||
'sentMessages'
|
'sentMessages'
|
||||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} post=${msBetween(
|
||||||
'kanbanGc'
|
'postStart',
|
||||||
)} resolveMembers=${msSince('resolveMembers')} runtimeAdvisories=${msSince(
|
'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'
|
'runtimeAdvisories'
|
||||||
)} enrichBranches=${msSince(
|
)}/enrichBranches=${msBetween(
|
||||||
|
'runtimeAdvisories',
|
||||||
'enrichBranches'
|
'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> {
|
async reconcileTeamArtifacts(
|
||||||
this.getController(teamName).maintenance.reconcileArtifacts({
|
teamName: string,
|
||||||
reason: 'file-watch',
|
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[] {
|
private getLeadProjectDirCandidates(projectPath: string): string[] {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,35 @@
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
|
|
||||||
const LOOKBACK_MS = 10 * 60 * 1000;
|
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 5_000;
|
const CACHE_TTL_MS = 5_000;
|
||||||
const TAIL_BYTES = 64 * 1024;
|
const TAIL_BYTES = 64 * 1024;
|
||||||
|
const BATCH_WARN_MS = 200;
|
||||||
|
|
||||||
|
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||||
|
|
||||||
interface CachedRuntimeAdvisory {
|
interface CachedRuntimeAdvisory {
|
||||||
value: MemberRuntimeAdvisory | null;
|
value: MemberRuntimeAdvisory | null;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CachedTeamBatchAdvisories {
|
||||||
|
membersSignature: string;
|
||||||
|
value: Map<string, MemberRuntimeAdvisory>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamMemberRuntimeAdvisoryService {
|
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()) {}
|
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
|
||||||
|
|
||||||
|
|
@ -22,38 +37,162 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
||||||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||||
const advisoryEntries = await Promise.all(
|
const activeMembers = members.filter((member) => !member.removedAt);
|
||||||
members
|
if (activeMembers.length === 0) {
|
||||||
.filter((member) => !member.removedAt)
|
return new Map();
|
||||||
.map(async (member) => {
|
}
|
||||||
const advisory = await this.getMemberAdvisory(teamName, member.name);
|
|
||||||
return advisory ? ([member.name, advisory] as const) : null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Map(
|
const teamKey = this.normalizeToken(teamName);
|
||||||
advisoryEntries.filter(
|
const membersSignature = this.buildMembersSignature(activeMembers);
|
||||||
(entry): entry is readonly [string, MemberRuntimeAdvisory] => entry !== null
|
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(
|
async getMemberAdvisory(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
): Promise<MemberRuntimeAdvisory | null> {
|
): Promise<MemberRuntimeAdvisory | null> {
|
||||||
const cacheKey = `${teamName.toLowerCase()}::${memberName.toLowerCase()}`;
|
const cacheKey = this.getMemberCacheKey(teamName, memberName);
|
||||||
const cached = this.cache.get(cacheKey);
|
const cached = this.memberCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.value;
|
return cached.value ? this.cloneAdvisory(cached.value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||||
this.cache.set(cacheKey, {
|
this.memberCache.set(cacheKey, {
|
||||||
value: advisory,
|
value: advisory,
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
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(
|
private async findRecentMemberAdvisory(
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,38 @@ interface PromptSizeSummary {
|
||||||
|
|
||||||
const MEMBER_LAUNCH_GRACE_MS = 90_000;
|
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 {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -3557,7 +3589,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -6258,8 +6292,12 @@ export class TeamProvisioningService {
|
||||||
if (!registeredNames) {
|
if (!registeredNames) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
now - run.lastMemberSpawnAuditConfigReadWarningAt >=
|
shouldWarnOnUnreadableMemberAuditConfig({
|
||||||
MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS
|
nowMs: now,
|
||||||
|
lastWarnAt: run.lastMemberSpawnAuditConfigReadWarningAt,
|
||||||
|
expectedMembers: run.expectedMembers,
|
||||||
|
memberSpawnStatuses: run.memberSpawnStatuses,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
run.lastMemberSpawnAuditConfigReadWarningAt = now;
|
run.lastMemberSpawnAuditConfigReadWarningAt = now;
|
||||||
logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: config.json not readable`);
|
logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: config.json not readable`);
|
||||||
|
|
@ -6318,7 +6356,13 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastWarnAt = run.lastMemberSpawnAuditMissingWarningAt.get(expected) ?? 0;
|
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);
|
run.lastMemberSpawnAuditMissingWarningAt.set(expected, now);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] Member "${expected}" not found in config.json members after provisioning`
|
`[${run.teamName}] Member "${expected}" not found in config.json members after provisioning`
|
||||||
|
|
@ -6413,7 +6457,8 @@ export class TeamProvisioningService {
|
||||||
private getFailedSpawnMembers(
|
private getFailedSpawnMembers(
|
||||||
run: ProvisioningRun
|
run: ProvisioningRun
|
||||||
): { name: string; error?: string; updatedAt: string }[] {
|
): { 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')
|
.filter(([, entry]) => entry.launchState === 'failed_to_start')
|
||||||
.map(([name, entry]) => ({
|
.map(([name, entry]) => ({
|
||||||
name,
|
name,
|
||||||
|
|
@ -6429,12 +6474,14 @@ export class TeamProvisioningService {
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
runtimeAlivePendingCount: number;
|
runtimeAlivePendingCount: number;
|
||||||
} {
|
} {
|
||||||
|
const expectedMembers = run.expectedMembers ?? [];
|
||||||
|
const memberSpawnStatuses = run.memberSpawnStatuses ?? new Map();
|
||||||
let confirmedCount = 0;
|
let confirmedCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
for (const expected of run.expectedMembers) {
|
for (const expected of expectedMembers) {
|
||||||
const entry = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
|
const entry = memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
|
||||||
if (entry.launchState === 'confirmed_alive') {
|
if (entry.launchState === 'confirmed_alive') {
|
||||||
confirmedCount += 1;
|
confirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -9093,7 +9140,7 @@ export class TeamProvisioningService {
|
||||||
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
|
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
|
||||||
);
|
);
|
||||||
const hasPendingBootstrap =
|
const hasPendingBootstrap =
|
||||||
!hasSpawnFailures && stillStartingCount > 0 && run.expectedMembers.length > 0;
|
!hasSpawnFailures && stillStartingCount > 0 && (run.expectedMembers?.length ?? 0) > 0;
|
||||||
const readyMessage = hasSpawnFailures
|
const readyMessage = hasSpawnFailures
|
||||||
? `Launch completed with teammate errors — ${failedSpawnMembers
|
? `Launch completed with teammate errors — ${failedSpawnMembers
|
||||||
.map((member) => member.name)
|
.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 { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -69,12 +69,12 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
|
||||||
// Main component
|
// Main component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const ClaudeLogsSection = ({
|
export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
||||||
teamName,
|
teamName,
|
||||||
position = 'inline',
|
position = 'inline',
|
||||||
sidebarViewerMaxHeight,
|
sidebarViewerMaxHeight,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClaudeLogsSectionProps): React.JSX.Element => {
|
}: ClaudeLogsSectionProps): React.JSX.Element {
|
||||||
const ctrl = useClaudeLogsController(teamName);
|
const ctrl = useClaudeLogsController(teamName);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -151,4 +151,4 @@ export const ClaudeLogsSection = ({
|
||||||
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
|
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import { MemberHoverCard } from './members/MemberHoverCard';
|
||||||
interface MemberBadgeProps {
|
interface MemberBadgeProps {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Owning team context for hover-card store lookups. */
|
||||||
|
teamName?: string;
|
||||||
/** Avatar + badge size variant */
|
/** Avatar + badge size variant */
|
||||||
size?: 'xs' | 'sm' | 'md';
|
size?: 'xs' | 'sm' | 'md';
|
||||||
/** Hide the avatar icon, show only the name badge */
|
/** Hide the avatar icon, show only the name badge */
|
||||||
|
|
@ -30,6 +32,7 @@ interface MemberBadgeProps {
|
||||||
export const MemberBadge = ({
|
export const MemberBadge = ({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
|
teamName,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
hideAvatar,
|
hideAvatar,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -93,7 +96,7 @@ export const MemberBadge = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberHoverCard name={name} color={color}>
|
<MemberHoverCard name={name} color={color} teamName={teamName}>
|
||||||
{content}
|
{content}
|
||||||
</MemberHoverCard>
|
</MemberHoverCard>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberBadge } from './MemberBadge';
|
import { MemberBadge } from './MemberBadge';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember, TeamProcess } from '@shared/types';
|
||||||
|
|
||||||
function formatShortTime(date: Date): string {
|
function formatShortTime(date: Date): string {
|
||||||
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
|
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
|
||||||
return distance
|
return distance
|
||||||
|
|
@ -23,15 +26,61 @@ function formatShortTime(date: Date): string {
|
||||||
.replace(' year', 'y');
|
.replace(' year', 'y');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProcessesSection = (): React.JSX.Element | null => {
|
interface ProcessesSectionProps {
|
||||||
const teamName = useStore((s) => s.selectedTeamName);
|
teamName: string;
|
||||||
const data = useStore((s) => s.selectedTeamData);
|
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 aAlive = !a.stoppedAt;
|
||||||
const bAlive = !b.stoppedAt;
|
const bAlive = !b.stoppedAt;
|
||||||
if (aAlive !== bAlive) return aAlive ? -1 : 1;
|
if (aAlive !== bAlive) return aAlive ? -1 : 1;
|
||||||
|
|
@ -119,6 +168,7 @@ export const ProcessesSection = (): React.JSX.Element | null => {
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={proc.registeredBy}
|
name={proc.registeredBy}
|
||||||
color={memberColorMap.get(proc.registeredBy)}
|
color={memberColorMap.get(proc.registeredBy)}
|
||||||
|
teamName={teamName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
|
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
|
||||||
|
|
@ -128,4 +178,15 @@ export const ProcessesSection = (): React.JSX.Element | null => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</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',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element => {
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
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 globalTasks = useStore((s) => s.globalTasks);
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
const teamByName = useStore((s) => s.teamByName);
|
||||||
|
|
||||||
|
|
@ -161,7 +166,11 @@ export const TaskTooltip = ({
|
||||||
|
|
||||||
{/* Owner */}
|
{/* Owner */}
|
||||||
{task.owner && members.length > 0 ? (
|
{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 ? (
|
) : task.owner ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-secondary)]">{task.owner}</span>
|
<span className="text-[10px] text-[var(--color-text-secondary)]">{task.owner}</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
type TaskChangeRequestOptions,
|
type TaskChangeRequestOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
|
|
@ -87,6 +88,10 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
|
import {
|
||||||
|
isLeadSessionMissing,
|
||||||
|
shouldSuppressMissingLeadSessionFetch,
|
||||||
|
} from './teamSessionFetchGuards';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
|
|
@ -117,6 +122,13 @@ interface CreateTaskDialogState {
|
||||||
defaultChip?: InlineChip;
|
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(
|
function areResolvedMembersEqual(
|
||||||
prev: readonly ResolvedTeamMember[],
|
prev: readonly ResolvedTeamMember[],
|
||||||
next: readonly ResolvedTeamMember[]
|
next: readonly ResolvedTeamMember[]
|
||||||
|
|
@ -140,7 +152,12 @@ function areResolvedMembersEqual(
|
||||||
prevMember.effort !== nextMember.effort ||
|
prevMember.effort !== nextMember.effort ||
|
||||||
prevMember.cwd !== nextMember.cwd ||
|
prevMember.cwd !== nextMember.cwd ||
|
||||||
prevMember.gitBranch !== nextMember.gitBranch ||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +206,13 @@ export const TeamDetailView = ({
|
||||||
teamName,
|
teamName,
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamDetailViewProps): React.JSX.Element => {
|
}: 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 { isLight } = useTheme();
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||||
|
|
@ -212,6 +236,7 @@ export const TeamDetailView = ({
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||||
const wasProvisioningRef = useRef(false);
|
const wasProvisioningRef = useRef(false);
|
||||||
|
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Set inert on background content when editor/graph overlay is open (a11y focus trap)
|
// Set inert on background content when editor/graph overlay is open (a11y focus trap)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -405,6 +430,7 @@ export const TeamDetailView = ({
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||||
|
const missingLeadSessionFetchKeyRef = useRef<string | null>(null);
|
||||||
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
selectedOwners: new Set(),
|
selectedOwners: new Set(),
|
||||||
|
|
@ -418,7 +444,6 @@ export const TeamDetailView = ({
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
teams,
|
|
||||||
fetchSessionDetail,
|
fetchSessionDetail,
|
||||||
initTabUIState,
|
initTabUIState,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
|
|
@ -444,7 +469,7 @@ export const TeamDetailView = ({
|
||||||
provisioningError,
|
provisioningError,
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivityByTeam,
|
leadActivity,
|
||||||
leadContextUpdatedAt,
|
leadContextUpdatedAt,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses,
|
||||||
|
|
@ -464,12 +489,8 @@ export const TeamDetailView = ({
|
||||||
setSidebarLogsHeight,
|
setSidebarLogsHeight,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
data: s.selectedTeamData,
|
|
||||||
loading: s.selectedTeamLoading,
|
|
||||||
error: s.selectedTeamError,
|
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
teams: s.teams,
|
|
||||||
fetchSessionDetail: s.fetchSessionDetail,
|
fetchSessionDetail: s.fetchSessionDetail,
|
||||||
initTabUIState: s.initTabUIState,
|
initTabUIState: s.initTabUIState,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
|
|
@ -495,7 +516,10 @@ export const TeamDetailView = ({
|
||||||
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
|
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
|
||||||
clearProvisioningError: s.clearProvisioningError,
|
clearProvisioningError: s.clearProvisioningError,
|
||||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
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,
|
leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined,
|
||||||
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||||||
|
|
@ -528,6 +552,39 @@ export const TeamDetailView = ({
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
const [isContextButtonHovered, setIsContextButtonHovered] = useState(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
|
// Messages panel resize
|
||||||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||||||
useResizablePanel({
|
useResizablePanel({
|
||||||
|
|
@ -565,7 +622,6 @@ export const TeamDetailView = ({
|
||||||
|
|
||||||
// Fetch initial spawn statuses when provisioning starts
|
// Fetch initial spawn statuses when provisioning starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const leadActivity = teamName ? leadActivityByTeam[teamName] : undefined;
|
|
||||||
const shouldFetchSpawnStatuses =
|
const shouldFetchSpawnStatuses =
|
||||||
Boolean(teamName) &&
|
Boolean(teamName) &&
|
||||||
(isTeamProvisioning ||
|
(isTeamProvisioning ||
|
||||||
|
|
@ -578,7 +634,7 @@ export const TeamDetailView = ({
|
||||||
data?.isAlive,
|
data?.isAlive,
|
||||||
fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivityByTeam,
|
leadActivity,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
teamName,
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
@ -644,12 +700,13 @@ export const TeamDetailView = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!launchDialogOpen) return;
|
if (!launchDialogOpen) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const teamsSnapshot = useStore.getState().teams;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const aliveList = await api.teams.aliveList();
|
const aliveList = await api.teams.aliveList();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const aliveSet = new Set(aliveList);
|
const aliveSet = new Set(aliveList);
|
||||||
const refs = teams
|
const refs = teamsSnapshot
|
||||||
.filter((t) => aliveSet.has(t.teamName) && t.projectPath)
|
.filter((t) => aliveSet.has(t.teamName) && t.projectPath)
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
teamName: t.teamName,
|
teamName: t.teamName,
|
||||||
|
|
@ -664,7 +721,7 @@ export const TeamDetailView = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [launchDialogOpen, teams]);
|
}, [launchDialogOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (kanbanFilterQuery) {
|
if (kanbanFilterQuery) {
|
||||||
|
|
@ -673,9 +730,22 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||||||
|
|
||||||
const currentTeamSummary = useMemo(
|
const currentTeamSummary = useStore(
|
||||||
() => teams.find((team) => team.teamName === teamName) ?? null,
|
useShallow((s) => {
|
||||||
[teams, teamName]
|
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
|
// Load sessions for the team's project
|
||||||
|
|
@ -695,6 +765,14 @@ export const TeamDetailView = ({
|
||||||
const leadSessionLoaded = Boolean(
|
const leadSessionLoaded = Boolean(
|
||||||
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
|
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 leadSubagentCostUsd = useMemo(() => {
|
||||||
const processes = leadSessionDetail?.processes;
|
const processes = leadSessionDetail?.processes;
|
||||||
|
|
@ -773,6 +851,10 @@ export const TeamDetailView = ({
|
||||||
[visibleContextTokens, lastAiGroupTotalTokens]
|
[visibleContextTokens, lastAiGroupTotalTokens]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
missingLeadSessionFetchKeyRef.current = null;
|
||||||
|
}, [missingLeadSessionFetchKey]);
|
||||||
|
|
||||||
// Keep lead-session context fresh in the background while the team tab is active.
|
// 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.
|
// This keeps the button value current even when the panel is closed.
|
||||||
// For offline teams: fetch once on mount so the percentage shows immediately.
|
// For offline teams: fetch once on mount so the percentage shows immediately.
|
||||||
|
|
@ -781,32 +863,76 @@ export const TeamDetailView = ({
|
||||||
if (!isThisTabActive) return;
|
if (!isThisTabActive) return;
|
||||||
if (!tabId || !projectId || !leadSessionId) 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;
|
if (!data?.isAlive) return;
|
||||||
|
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
fetchLeadSessionDetail();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
return () => window.clearInterval(id);
|
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.
|
// 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
|
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
||||||
// refresh loop here, the Messages panel can keep showing stale "awaiting reply"
|
// don't keep rewriting the whole team snapshot every 2 seconds.
|
||||||
// state even though the lead has already answered.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (pendingReplyRefreshTimerRef.current != null) {
|
||||||
|
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||||
|
pendingReplyRefreshTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isThisTabActive) return;
|
if (!isThisTabActive) return;
|
||||||
if (!data) return;
|
if (!data?.isAlive) return;
|
||||||
if (Object.keys(pendingRepliesByMember).length === 0) 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(() => {
|
return () => {
|
||||||
void refreshTeamData(teamName);
|
if (pendingReplyRefreshTimerRef.current != null) {
|
||||||
}, 2_000);
|
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||||
|
pendingReplyRefreshTimerRef.current = null;
|
||||||
return () => window.clearInterval(id);
|
}
|
||||||
|
};
|
||||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -933,25 +1059,25 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
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 memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||||
|
|
||||||
const openCreateTaskDialog = (
|
const openCreateTaskDialog = useCallback(
|
||||||
subject = '',
|
(subject = '', description = '', owner = '', startImmediately?: boolean): void => {
|
||||||
description = '',
|
setCreateTaskDialog({
|
||||||
owner = '',
|
open: true,
|
||||||
startImmediately?: boolean
|
defaultSubject: subject,
|
||||||
): void => {
|
defaultDescription: description,
|
||||||
setCreateTaskDialog({
|
defaultOwner: owner,
|
||||||
open: true,
|
defaultStartImmediately: startImmediately,
|
||||||
defaultSubject: subject,
|
});
|
||||||
defaultDescription: description,
|
},
|
||||||
defaultOwner: owner,
|
[]
|
||||||
defaultStartImmediately: startImmediately,
|
);
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeCreateTaskDialog = (): void => {
|
const closeCreateTaskDialog = useCallback((): void => {
|
||||||
setCreateTaskDialog({
|
setCreateTaskDialog({
|
||||||
open: false,
|
open: false,
|
||||||
defaultSubject: '',
|
defaultSubject: '',
|
||||||
|
|
@ -959,7 +1085,7 @@ export const TeamDetailView = ({
|
||||||
defaultOwner: '',
|
defaultOwner: '',
|
||||||
defaultStartImmediately: undefined,
|
defaultStartImmediately: undefined,
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => {
|
const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => {
|
||||||
openCreateTaskDialog(subject, description);
|
openCreateTaskDialog(subject, description);
|
||||||
|
|
@ -977,6 +1103,36 @@ export const TeamDetailView = ({
|
||||||
setLaunchDialogOpen(true);
|
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(
|
const handleTaskIdClick = useCallback(
|
||||||
(taskId: string) => {
|
(taskId: string) => {
|
||||||
const task =
|
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) {
|
if (!teamName) {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center p-6 text-sm text-red-400">
|
<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') {
|
if (error === 'TEAM_DRAFT') {
|
||||||
const teamSummary = teams.find((t) => t.teamName === teamName);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="size-full overflow-auto p-6">
|
<div className="size-full overflow-auto p-6">
|
||||||
|
|
@ -1208,10 +1407,11 @@ export const TeamDetailView = ({
|
||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
||||||
<p className="mt-2 text-xs text-text-secondary">
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
This is a draft team — <strong>{teamSummary?.displayName || teamName}</strong> has
|
This is a draft team —{' '}
|
||||||
been configured with {teamSummary?.memberCount ?? 0} member
|
<strong>{currentTeamSummary?.displayName || teamName}</strong> has been configured
|
||||||
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI
|
with {currentTeamSummary?.memberCount ?? 0} member
|
||||||
yet. Click Launch to select a model and start the team.
|
{currentTeamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned
|
||||||
|
by CLI yet. Click Launch to select a model and start the team.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex justify-center gap-2">
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1237,7 +1437,7 @@ export const TeamDetailView = ({
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={[]}
|
members={[]}
|
||||||
defaultProjectPath={teamSummary?.projectPath}
|
defaultProjectPath={currentTeamSummary?.projectPath}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
clearProvisioningError={clearProvisioningError}
|
clearProvisioningError={clearProvisioningError}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
|
|
@ -1276,27 +1476,6 @@ export const TeamDetailView = ({
|
||||||
const headerColorSet = data.config.color
|
const headerColorSet = data.config.color
|
||||||
? getTeamColorSet(data.config.color)
|
? getTeamColorSet(data.config.color)
|
||||||
: nameColorSet(data.config.name);
|
: 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 (
|
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.confirmedCount ?? 0}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.memberCount} teammates confirmed alive, ${currentTeamSummary.runtimeAlivePendingCount} runtime${currentTeamSummary.runtimeAlivePendingCount === 1 ? '' : 's'} pending bootstrap`
|
||||||
: 'Last launch is still reconciling'
|
: 'Last launch is still reconciling'
|
||||||
: currentTeamSummary?.partialLaunchFailure
|
: currentTeamSummary?.partialLaunchFailure
|
||||||
? currentTeamSummary.missingMembers?.length
|
? currentTeamSummary.missingMemberCount > 0
|
||||||
? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join`
|
? `Last launch failed partway — ${currentTeamSummary.missingMemberCount}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMemberCount} teammates did not join`
|
||||||
: 'Last launch failed partway'
|
: 'Last launch failed partway'
|
||||||
: 'Team is offline'}
|
: 'Team is offline'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1678,20 +1857,12 @@ export const TeamDetailView = ({
|
||||||
memberSpawnStatuses={memberSpawnStatusMap}
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivityByTeam[teamName]}
|
leadActivity={leadActivity}
|
||||||
launchParams={launchParams}
|
launchParams={launchParams}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={handleSelectMember}
|
||||||
onSendMessage={(member) => {
|
onSendMessage={handleSendMessageToMember}
|
||||||
setSendDialogRecipient(member.name);
|
onAssignTask={handleAssignTaskToMember}
|
||||||
setSendDialogDefaultText(undefined);
|
onOpenTask={handleOpenTaskById}
|
||||||
setSendDialogDefaultChip(undefined);
|
|
||||||
setReplyQuote(undefined);
|
|
||||||
setSendDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onAssignTask={(member) => {
|
|
||||||
openCreateTaskDialog('', '', member.name);
|
|
||||||
}}
|
|
||||||
onOpenTask={(task) => setSelectedTask(task)}
|
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -1922,7 +2093,11 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<ProcessesSection />
|
<ProcessesSection
|
||||||
|
teamName={teamName}
|
||||||
|
members={data.members}
|
||||||
|
processes={data.processes}
|
||||||
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1965,7 +2140,8 @@ export const TeamDetailView = ({
|
||||||
messages={data.messages}
|
messages={data.messages}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivityByTeam[teamName]}
|
leadActivity={leadActivity}
|
||||||
|
spawnEntry={selectedMember ? memberSpawnStatusMap?.get(selectedMember.name) : undefined}
|
||||||
onClose={() => setSelectedMember(null)}
|
onClose={() => setSelectedMember(null)}
|
||||||
onSendMessage={() => {
|
onSendMessage={() => {
|
||||||
const name = selectedMember?.name ?? '';
|
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 { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -10,19 +10,43 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
|
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
|
||||||
import { getDisplayStepIndex } from './provisioningSteps';
|
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 {
|
interface TeamProvisioningBannerProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamProvisioningBanner = ({
|
export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
||||||
teamName,
|
teamName,
|
||||||
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
|
}: TeamProvisioningBannerProps): React.JSX.Element | null {
|
||||||
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } =
|
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } =
|
||||||
useStore(
|
useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
cancelProvisioning: s.cancelProvisioning,
|
cancelProvisioning: s.cancelProvisioning,
|
||||||
teamMembers: s.selectedTeamData?.members,
|
teamMembers: s.selectedTeamName === teamName ? s.selectedTeamData?.members : undefined,
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
}))
|
}))
|
||||||
|
|
@ -126,6 +150,14 @@ export const TeamProvisioningBanner = ({
|
||||||
const entry = memberSpawnStatuses?.[member.name];
|
const entry = memberSpawnStatuses?.[member.name];
|
||||||
return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true;
|
return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true;
|
||||||
}).length;
|
}).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
|
const pendingSpawnCount = snapshotSummary
|
||||||
? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount)
|
? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount)
|
||||||
: teammates.filter((member) => {
|
: teammates.filter((member) => {
|
||||||
|
|
@ -144,8 +176,16 @@ export const TeamProvisioningBanner = ({
|
||||||
heartbeatConfirmedCount === 0 &&
|
heartbeatConfirmedCount === 0 &&
|
||||||
processOnlyAliveCount === fallbackTeammateCount &&
|
processOnlyAliveCount === fallbackTeammateCount &&
|
||||||
pendingSpawnCount === 0;
|
pendingSpawnCount === 0;
|
||||||
|
const hasMembersStillJoining =
|
||||||
|
fallbackTeammateCount > 0 &&
|
||||||
|
failedSpawnCount === 0 &&
|
||||||
|
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
||||||
|
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
|
const processOnlyAlivePhrase = formatProcessOnlyAlivePhrase(
|
||||||
|
processOnlyAliveCount,
|
||||||
|
retryingRuntimeCount
|
||||||
|
);
|
||||||
const readyDetailMessage =
|
const readyDetailMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? progress.message
|
? progress.message
|
||||||
|
|
@ -154,12 +194,14 @@ export const TeamProvisioningBanner = ({
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
? `Team provisioned — all ${fallbackTeammateCount} teammates made contact`
|
? `Team provisioned — all ${fallbackTeammateCount} teammates made contact`
|
||||||
: allPendingRuntimesStarted
|
: allPendingRuntimesStarted
|
||||||
? 'Team provisioned — teammates online'
|
? processOnlyAlivePhrase
|
||||||
|
? `Team provisioned — ${processOnlyAlivePhrase}`
|
||||||
|
: 'Team provisioned — teammates online'
|
||||||
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
|
: 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';
|
: 'Team provisioned — teammates are still starting';
|
||||||
const readyDetailSeverity =
|
const readyDetailSeverity =
|
||||||
failedSpawnCount > 0 || pendingSpawnCount > 0 ? 'warning' : undefined;
|
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
|
||||||
const readyMessage =
|
const readyMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? `Launch finished with errors — ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
|
? `Launch finished with errors — ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
|
||||||
|
|
@ -168,10 +210,13 @@ export const TeamProvisioningBanner = ({
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
? `Team launched — all ${fallbackTeammateCount} teammates made contact`
|
? `Team launched — all ${fallbackTeammateCount} teammates made contact`
|
||||||
: allPendingRuntimesStarted
|
: allPendingRuntimesStarted
|
||||||
? 'Team launched — teammates online'
|
? processOnlyAlivePhrase
|
||||||
|
? `Team launched — ${processOnlyAlivePhrase}`
|
||||||
|
: 'Team launched — teammates online'
|
||||||
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
|
: 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';
|
: 'Team launched — teammates are still starting';
|
||||||
|
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|
@ -180,7 +225,7 @@ export const TeamProvisioningBanner = ({
|
||||||
title="Launch details"
|
title="Launch details"
|
||||||
message={failedSpawnCount > 0 ? readyDetailMessage : null}
|
message={failedSpawnCount > 0 ? readyDetailMessage : null}
|
||||||
messageSeverity={readyDetailSeverity}
|
messageSeverity={readyDetailSeverity}
|
||||||
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
|
currentStepIndex={readyStepIndex}
|
||||||
startedAt={progress.startedAt}
|
startedAt={progress.startedAt}
|
||||||
pid={progress.pid}
|
pid={progress.pid}
|
||||||
cliLogsTail={progress.cliLogsTail}
|
cliLogsTail={progress.cliLogsTail}
|
||||||
|
|
@ -189,7 +234,7 @@ export const TeamProvisioningBanner = ({
|
||||||
onCancel={null}
|
onCancel={null}
|
||||||
successMessage={readyMessage}
|
successMessage={readyMessage}
|
||||||
successMessageSeverity={
|
successMessageSeverity={
|
||||||
failedSpawnCount > 0 || pendingSpawnCount > 0 ? 'warning' : 'success'
|
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : 'success'
|
||||||
}
|
}
|
||||||
onDismiss={() => setDismissed(true)}
|
onDismiss={() => setDismissed(true)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -225,4 +270,6 @@ export const TeamProvisioningBanner = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const DISPLAY_COMPLETE_STEP_INDEX = 4;
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@ const NoiseRow = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const BootstrapSystemRow = ({
|
const BootstrapSystemRow = ({
|
||||||
|
teamName,
|
||||||
senderName,
|
senderName,
|
||||||
recipientName,
|
recipientName,
|
||||||
runtime,
|
runtime,
|
||||||
|
|
@ -314,6 +315,7 @@ const BootstrapSystemRow = ({
|
||||||
timestamp,
|
timestamp,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
}: {
|
}: {
|
||||||
|
teamName: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
runtime?: 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">
|
<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
|
start
|
||||||
</span>
|
</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" />
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={recipientName}
|
name={recipientName}
|
||||||
color={recipientColor}
|
color={recipientColor}
|
||||||
|
teamName={teamName}
|
||||||
hideAvatar
|
hideAvatar
|
||||||
onClick={onMemberNameClick}
|
onClick={onMemberNameClick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -344,6 +353,7 @@ const BootstrapSystemRow = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const BootstrapAcknowledgementRow = ({
|
const BootstrapAcknowledgementRow = ({
|
||||||
|
teamName,
|
||||||
senderName,
|
senderName,
|
||||||
recipientName,
|
recipientName,
|
||||||
senderColor,
|
senderColor,
|
||||||
|
|
@ -351,6 +361,7 @@ const BootstrapAcknowledgementRow = ({
|
||||||
timestamp,
|
timestamp,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
}: {
|
}: {
|
||||||
|
teamName: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
senderColor?: 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">
|
<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
|
bootstrap
|
||||||
</span>
|
</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" />
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={recipientName}
|
name={recipientName}
|
||||||
color={recipientColor}
|
color={recipientColor}
|
||||||
|
teamName={teamName}
|
||||||
hideAvatar
|
hideAvatar
|
||||||
onClick={onMemberNameClick}
|
onClick={onMemberNameClick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -736,6 +754,7 @@ export const ActivityItem = memo(
|
||||||
if (bootstrapDisplay) {
|
if (bootstrapDisplay) {
|
||||||
return (
|
return (
|
||||||
<BootstrapSystemRow
|
<BootstrapSystemRow
|
||||||
|
teamName={teamName}
|
||||||
senderName={senderName}
|
senderName={senderName}
|
||||||
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
|
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
|
||||||
runtime={bootstrapDisplay.runtime}
|
runtime={bootstrapDisplay.runtime}
|
||||||
|
|
@ -750,6 +769,7 @@ export const ActivityItem = memo(
|
||||||
if (bootstrapAcknowledgement) {
|
if (bootstrapAcknowledgement) {
|
||||||
return (
|
return (
|
||||||
<BootstrapAcknowledgementRow
|
<BootstrapAcknowledgementRow
|
||||||
|
teamName={teamName}
|
||||||
senderName={senderName}
|
senderName={senderName}
|
||||||
recipientName={message.to ?? 'lead'}
|
recipientName={message.to ?? 'lead'}
|
||||||
senderColor={senderColor}
|
senderColor={senderColor}
|
||||||
|
|
@ -875,6 +895,7 @@ export const ActivityItem = memo(
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={senderName}
|
name={senderName}
|
||||||
color={senderColor}
|
color={senderColor}
|
||||||
|
teamName={teamName}
|
||||||
hideAvatar={senderHideAvatar || compactHeader}
|
hideAvatar={senderHideAvatar || compactHeader}
|
||||||
onClick={onMemberNameClick}
|
onClick={onMemberNameClick}
|
||||||
disableHoverCard={crossTeamOrigin != null}
|
disableHoverCard={crossTeamOrigin != null}
|
||||||
|
|
@ -962,6 +983,7 @@ export const ActivityItem = memo(
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
|
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
|
||||||
color={crossTeamTarget ? undefined : recipientColor}
|
color={crossTeamTarget ? undefined : recipientColor}
|
||||||
|
teamName={crossTeamTarget ? undefined : teamName}
|
||||||
hideAvatar={
|
hideAvatar={
|
||||||
compactHeader ||
|
compactHeader ||
|
||||||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===
|
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
areStringMapsEqual,
|
areStringMapsEqual,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
|
@ -80,6 +81,9 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
|
||||||
const EMPTY_TEAM_NAMES: string[] = [];
|
const EMPTY_TEAM_NAMES: string[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
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 {
|
interface ItemCollapseProps {
|
||||||
collapseMode: 'default' | 'managed';
|
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
|
// 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.
|
// don't consume the page limit — they collapse into a single visual group anyway.
|
||||||
const { visibleMessages, hiddenCount } = useMemo(() => {
|
const { visibleMessages, hiddenCount } = useMemo(() => {
|
||||||
|
const startedAt = performance.now();
|
||||||
const total = messages.length;
|
const total = messages.length;
|
||||||
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
||||||
|
|
||||||
|
|
@ -354,14 +359,31 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
significantSeen +
|
significantSeen +
|
||||||
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
||||||
const hidden = Math.max(0, significantTotal - visibleCount);
|
const hidden = Math.max(0, significantTotal - visibleCount);
|
||||||
return {
|
const result = {
|
||||||
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
||||||
hiddenCount: hidden,
|
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]);
|
}, [messages, visibleCount]);
|
||||||
|
|
||||||
// Group consecutive lead thoughts into collapsible blocks.
|
// 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
|
// 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.
|
// new live messages at the top does not recolor every existing card.
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
|
getLaunchAwarePresenceLabel,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
getMemberRuntimeAdvisoryTitle,
|
getMemberRuntimeAdvisoryTitle,
|
||||||
getSpawnAwareDotClass,
|
getSpawnAwareDotClass,
|
||||||
getSpawnAwarePresenceLabel,
|
|
||||||
getSpawnCardClass,
|
getSpawnCardClass,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -83,23 +83,31 @@ export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
);
|
);
|
||||||
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
|
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
|
||||||
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
|
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
|
||||||
const presenceLabel = getSpawnAwarePresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
member.runtimeAdvisory,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
);
|
);
|
||||||
const spawnCardClass = isTeamProvisioning ? getSpawnCardClass(spawnStatus) : '';
|
const spawnCardClass = getSpawnCardClass(
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning
|
||||||
|
);
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const pending = taskCounts?.pending ?? 0;
|
const pending = taskCounts?.pending ?? 0;
|
||||||
|
|
@ -113,6 +121,12 @@ export const MemberCard = ({
|
||||||
: reviewTask
|
: reviewTask
|
||||||
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const showStartingSkeleton =
|
||||||
|
!isRemoved &&
|
||||||
|
presenceLabel === 'starting' &&
|
||||||
|
spawnLaunchState !== 'failed_to_start' &&
|
||||||
|
!activityTask;
|
||||||
|
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -191,7 +205,18 @@ export const MemberCard = ({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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)]">
|
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
{runtimeSummary}
|
{runtimeSummary}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,7 +230,20 @@ export const MemberCard = ({
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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 ? (
|
!isRemoved ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||||
|
|
@ -236,26 +274,42 @@ export const MemberCard = ({
|
||||||
{isRemoved ? 'removed' : presenceLabel}
|
{isRemoved ? 'removed' : presenceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
{showStartingSkeleton ? (
|
||||||
className="shrink-0"
|
<div className="shrink-0" aria-hidden="true">
|
||||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
<div
|
||||||
>
|
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
|
||||||
<Badge
|
style={{
|
||||||
variant="secondary"
|
backgroundColor: 'var(--skeleton-base-dim)',
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
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
|
||||||
</Badge>
|
variant="secondary"
|
||||||
{totalTasks > 0 && (
|
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
>
|
||||||
<div
|
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
</Badge>
|
||||||
style={{ width: `${progressPercent}%` }}
|
{totalTasks > 0 && (
|
||||||
/>
|
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||||
</div>
|
<div
|
||||||
)}
|
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||||
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
style={{ width: `${progressPercent}%` }}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isRemoved && (
|
{!isRemoved && (
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { MemberTasksTab } from './MemberTasksTab';
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -30,6 +31,7 @@ interface MemberDetailDialogProps {
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
onAssignTask: () => void;
|
onAssignTask: () => void;
|
||||||
|
|
@ -49,6 +51,7 @@ export const MemberDetailDialog = ({
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
spawnEntry,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -100,6 +103,10 @@ export const MemberDetailDialog = ({
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
||||||
|
spawnStatus={spawnEntry?.status}
|
||||||
|
spawnLaunchState={spawnEntry?.launchState}
|
||||||
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||||
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,31 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
getMemberDotClass,
|
getLaunchAwarePresenceLabel,
|
||||||
getPresenceLabel,
|
getSpawnAwareDotClass,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||||
|
|
||||||
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
|
import type {
|
||||||
|
LeadActivityState,
|
||||||
|
MemberLaunchState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
|
MemberSpawnStatus,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailHeaderProps {
|
interface MemberDetailHeaderProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
|
spawnRuntimeAlive?: boolean;
|
||||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
updatingRole?: boolean;
|
updatingRole?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +41,10 @@ export const MemberDetailHeader = ({
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnLivenessSource,
|
||||||
|
spawnRuntimeAlive,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
updatingRole,
|
updatingRole,
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
|
|
@ -44,8 +58,26 @@ export const MemberDetailHeader = ({
|
||||||
|
|
||||||
const colors = getTeamColorSet(member.color ?? '');
|
const colors = getTeamColorSet(member.color ?? '');
|
||||||
const role = member.role || formatAgentRole(member.agentType);
|
const role = member.role || formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
member,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnLivenessSource,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
member.runtimeAdvisory,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
leadActivity
|
||||||
|
);
|
||||||
|
const dotClass = getSpawnAwareDotClass(
|
||||||
|
member,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
leadActivity
|
||||||
|
);
|
||||||
|
|
||||||
const canEditRole =
|
const canEditRole =
|
||||||
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
getMemberDotClass,
|
getLaunchAwarePresenceLabel,
|
||||||
getPresenceLabel,
|
getSpawnAwareDotClass,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
|
@ -27,6 +27,8 @@ interface MemberHoverCardProps {
|
||||||
name: string;
|
name: string;
|
||||||
/** Color key for the member */
|
/** Color key for the member */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Owning team context for store lookups. */
|
||||||
|
teamName?: string;
|
||||||
/** Called when user clicks on the current task */
|
/** Called when user clicks on the current task */
|
||||||
onOpenTask?: (task: TeamTaskWithKanban) => void;
|
onOpenTask?: (task: TeamTaskWithKanban) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -40,18 +42,34 @@ interface MemberHoverCardProps {
|
||||||
export const MemberHoverCard = ({
|
export const MemberHoverCard = ({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
|
teamName,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
children,
|
children,
|
||||||
}: MemberHoverCardProps): React.JSX.Element => {
|
}: MemberHoverCardProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null);
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive);
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
const teamName = useStore((s) => s.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) =>
|
const leadActivity: LeadActivityState | undefined = useStore((s) =>
|
||||||
teamName ? s.leadActivityByTeam[teamName] : undefined
|
effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined
|
||||||
);
|
);
|
||||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
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) {
|
if (!member) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
@ -59,14 +77,22 @@ export const MemberHoverCard = ({
|
||||||
|
|
||||||
const colors = getTeamColorSet(color ?? member.color ?? '');
|
const colors = getTeamColorSet(color ?? member.color ?? '');
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
|
spawnEntry?.status,
|
||||||
|
spawnEntry?.launchState,
|
||||||
|
spawnEntry?.livenessSource,
|
||||||
|
spawnEntry?.runtimeAlive,
|
||||||
|
member.runtimeAdvisory,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
isLeadMember(member) ? leadActivity : undefined
|
isLeadMember(member) ? leadActivity : undefined
|
||||||
);
|
);
|
||||||
const dotClass = getMemberDotClass(
|
const dotClass = getSpawnAwareDotClass(
|
||||||
member,
|
member,
|
||||||
|
spawnEntry?.status,
|
||||||
|
spawnEntry?.launchState,
|
||||||
|
spawnEntry?.runtimeAlive,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
isLeadMember(member) ? leadActivity : undefined
|
isLeadMember(member) ? leadActivity : undefined
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
|
|
@ -33,10 +33,165 @@ interface MemberListProps {
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (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,
|
members,
|
||||||
memberTaskCounts,
|
memberTaskCounts,
|
||||||
taskMap,
|
taskMap,
|
||||||
|
|
@ -50,7 +205,7 @@ export const MemberList = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: MemberListProps): React.JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isWide, setIsWide] = useState(false);
|
const [isWide, setIsWide] = useState(false);
|
||||||
|
|
||||||
|
|
@ -111,7 +266,7 @@ export const MemberList = ({
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
return (
|
return (
|
||||||
<MemberCard
|
<MemberCard
|
||||||
|
|
@ -132,8 +287,8 @@ export const MemberList = ({
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||||
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
||||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
|
||||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
onAssignTask={() => onAssignTask?.(member)}
|
||||||
|
|
@ -156,4 +311,4 @@ export const MemberList = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, areMemberListPropsEqual);
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,13 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
}, [members, recipient]);
|
}, [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) => {
|
const currentTeamColor = useStore((s) => {
|
||||||
|
if (s.selectedTeamName !== teamName) {
|
||||||
|
return nameColorSet(teamName).border;
|
||||||
|
}
|
||||||
const configColor = s.selectedTeamData?.config.color;
|
const configColor = s.selectedTeamData?.config.color;
|
||||||
if (configColor) return getTeamColorSet(configColor).border;
|
if (configColor) return getTeamColorSet(configColor).border;
|
||||||
const displayName = s.selectedTeamData?.config.name ?? teamName;
|
const displayName = s.selectedTeamData?.config.name ?? teamName;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
export interface MessagesFilterState {
|
export interface MessagesFilterState {
|
||||||
from: Set<string>;
|
from: Set<string>;
|
||||||
|
|
@ -19,6 +19,8 @@ export interface MessagesFilterState {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessagesFilterPopoverProps {
|
interface MessagesFilterPopoverProps {
|
||||||
|
teamName: string;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
filter: MessagesFilterState;
|
filter: MessagesFilterState;
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -43,6 +45,8 @@ function collectToOptions(messages: InboxMessage[]): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessagesFilterPopover = ({
|
export const MessagesFilterPopover = ({
|
||||||
|
teamName,
|
||||||
|
members,
|
||||||
filter,
|
filter,
|
||||||
messages,
|
messages,
|
||||||
open,
|
open,
|
||||||
|
|
@ -67,7 +71,6 @@ export const MessagesFilterPopover = ({
|
||||||
}
|
}
|
||||||
}, [open, filter.from, filter.to, filter.showNoise]);
|
}, [open, filter.from, filter.to, filter.showNoise]);
|
||||||
|
|
||||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
|
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
|
||||||
|
|
@ -151,6 +154,7 @@ export const MessagesFilterPopover = ({
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={name}
|
name={name}
|
||||||
color={colorMap.get(name)}
|
color={colorMap.get(name)}
|
||||||
|
teamName={teamName}
|
||||||
size="sm"
|
size="sm"
|
||||||
hideAvatar={name === 'user'}
|
hideAvatar={name === 'user'}
|
||||||
/>
|
/>
|
||||||
|
|
@ -177,6 +181,7 @@ export const MessagesFilterPopover = ({
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={name}
|
name={name}
|
||||||
color={colorMap.get(name)}
|
color={colorMap.get(name)}
|
||||||
|
teamName={teamName}
|
||||||
size="sm"
|
size="sm"
|
||||||
hideAvatar={name === 'user'}
|
hideAvatar={name === 'user'}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -44,6 +45,10 @@ interface TimeWindow {
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logger = createLogger('Component:MessagesPanel');
|
||||||
|
const MESSAGES_PANEL_FILTER_WARN_MS = 8;
|
||||||
|
const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6;
|
||||||
|
|
||||||
interface MessagesPanelProps {
|
interface MessagesPanelProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
position: 'sidebar' | 'inline';
|
position: 'sidebar' | 'inline';
|
||||||
|
|
@ -183,20 +188,40 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
}, [position, sidebarScrollTop]);
|
}, [position, sidebarScrollTop]);
|
||||||
|
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return filterTeamMessages(messages, {
|
const startedAt = performance.now();
|
||||||
|
const result = filterTeamMessages(messages, {
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
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]);
|
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
const activityTimelineMessages = useMemo(() => {
|
const activityTimelineMessages = useMemo(() => {
|
||||||
return filterTeamMessages(messages, {
|
const startedAt = performance.now();
|
||||||
|
const result = filterTeamMessages(messages, {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
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]);
|
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
const replyCandidateMessages = useMemo(
|
const replyCandidateMessages = useMemo(
|
||||||
|
|
@ -211,18 +236,32 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
// Resolve the expanded item from filtered messages
|
// Resolve the expanded item from filtered messages
|
||||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||||
|
const startedAt = performance.now();
|
||||||
if (!expandedItemKey) return null;
|
if (!expandedItemKey) return null;
|
||||||
if (!expandedItemKey.startsWith('thoughts-')) {
|
if (!expandedItemKey.startsWith('thoughts-')) {
|
||||||
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
|
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);
|
const allItems = groupTimelineItems(activityTimelineMessages);
|
||||||
return (
|
const result =
|
||||||
allItems.find(
|
allItems.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
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]);
|
}, [expandedItemKey, activityTimelineMessages]);
|
||||||
|
|
||||||
// Auto-clear stale expanded key
|
// Auto-clear stale expanded key
|
||||||
|
|
@ -361,6 +400,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MessagesFilterPopover
|
<MessagesFilterPopover
|
||||||
|
teamName={teamName}
|
||||||
|
members={members}
|
||||||
filter={messagesFilter}
|
filter={messagesFilter}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
open={messagesFilterOpen}
|
open={messagesFilterOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
||||||
import { MessagesPanel } from '../messages/MessagesPanel';
|
import { MessagesPanel } from '../messages/MessagesPanel';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { MouseEventHandler } from 'react';
|
import type { MouseEventHandler } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface TeamSidebarRailProps {
|
||||||
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamSidebarRail = ({
|
export const TeamSidebarRail = memo(function TeamSidebarRail({
|
||||||
teamName,
|
teamName,
|
||||||
messagesPanelProps,
|
messagesPanelProps,
|
||||||
isResizing,
|
isResizing,
|
||||||
|
|
@ -25,7 +25,7 @@ export const TeamSidebarRail = ({
|
||||||
logsHeight,
|
logsHeight,
|
||||||
isLogsResizing,
|
isLogsResizing,
|
||||||
onLogsResizeMouseDown,
|
onLogsResizeMouseDown,
|
||||||
}: TeamSidebarRailProps): React.JSX.Element => {
|
}: TeamSidebarRailProps): React.JSX.Element {
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
const logsSeparator = logsOpen ? (
|
const logsSeparator = logsOpen ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -64,4 +64,4 @@ export const TeamSidebarRail = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
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 ────────────────────────────────────────────────────────
|
// ── Data state ────────────────────────────────────────────────────────
|
||||||
const [loadedCount, setLoadedCount] = useState(PAGE_SIZE);
|
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;
|
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 {
|
interface GraphNodePopoverProps {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -246,7 +253,7 @@ const MemberPopoverContent = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
||||||
>
|
>
|
||||||
{node.spawnStatus}
|
{getSpawnStatusBadgeLabel(node.spawnStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
buildTaskChangeRequestOptions,
|
buildTaskChangeRequestOptions,
|
||||||
canDisplayTaskChangesForOptions,
|
canDisplayTaskChangesForOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
|
@ -32,7 +33,11 @@ import { createSessionSlice } from './slices/sessionSlice';
|
||||||
import { createSubagentSlice } from './slices/subagentSlice';
|
import { createSubagentSlice } from './slices/subagentSlice';
|
||||||
import { createTabSlice } from './slices/tabSlice';
|
import { createTabSlice } from './slices/tabSlice';
|
||||||
import { createTabUISlice } from './slices/tabUISlice';
|
import { createTabUISlice } from './slices/tabUISlice';
|
||||||
import { createTeamSlice } from './slices/teamSlice';
|
import {
|
||||||
|
createTeamSlice,
|
||||||
|
getLastResolvedTeamDataRefreshAt,
|
||||||
|
isTeamDataRefreshPending,
|
||||||
|
} from './slices/teamSlice';
|
||||||
import { createUISlice } from './slices/uiSlice';
|
import { createUISlice } from './slices/uiSlice';
|
||||||
import { createUpdateSlice } from './slices/updateSlice';
|
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 IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||||
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
||||||
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
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 =
|
const CURRENT_APP_VERSION =
|
||||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
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
|
// Store Creation
|
||||||
|
|
@ -166,6 +235,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
});
|
});
|
||||||
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const pendingProjectRefreshTimers = 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 teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let memberSpawnRefreshTimers = 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_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
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 = (
|
const buildToolActivityTimerKey = (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
|
|
@ -500,6 +584,83 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return tracked;
|
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) {
|
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
|
||||||
let trackedTeamNames = new Set<string>();
|
let trackedTeamNames = new Set<string>();
|
||||||
const syncVisibleTeamTracking = (): void => {
|
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) {
|
if (api.teams?.onTeamChange) {
|
||||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
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 = (() => {
|
const isIgnoredRuntimeRun = (() => {
|
||||||
if (!event.runId) return false;
|
if (!event.runId) return false;
|
||||||
const state = useStore.getState();
|
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
|
// Immediate in-memory update for lead activity — no filesystem refresh needed
|
||||||
if (event.type === 'lead-activity' && event.detail) {
|
if (event.type === 'lead-activity' && event.detail) {
|
||||||
if (isStaleRuntimeEvent) {
|
if (isStaleRuntimeEvent) {
|
||||||
|
|
@ -917,22 +1092,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
seedCurrentRunIdIfMissing();
|
seedCurrentRunIdIfMissing();
|
||||||
void useStore.getState().fetchMemberSpawnStatuses(event.teamName);
|
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
|
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
|
||||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
scheduleMemberSpawnStatusesRefresh(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live lead-message events: only refresh the visible team detail, not team/task lists.
|
// 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(() => {
|
const timer = setTimeout(() => {
|
||||||
teamRefreshTimers.delete(event.teamName);
|
teamRefreshTimers.delete(event.teamName);
|
||||||
const current = useStore.getState();
|
const current = useStore.getState();
|
||||||
void current.refreshTeamData(event.teamName);
|
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
return;
|
return;
|
||||||
|
|
@ -981,8 +1146,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}, TEAM_LIST_REFRESH_THROTTLE_MS);
|
}, TEAM_LIST_REFRESH_THROTTLE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config';
|
||||||
|
|
||||||
// Throttled refresh of global tasks list for sidebar.
|
// Throttled refresh of global tasks list for sidebar.
|
||||||
if (!globalTasksRefreshTimer) {
|
if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) {
|
||||||
globalTasksRefreshTimer = setTimeout(() => {
|
globalTasksRefreshTimer = setTimeout(() => {
|
||||||
globalTasksRefreshTimer = null;
|
globalTasksRefreshTimer = null;
|
||||||
void useStore.getState().fetchAllTasks();
|
void useStore.getState().fetchAllTasks();
|
||||||
|
|
@ -1002,7 +1169,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamRefreshTimers.delete(event.teamName);
|
teamRefreshTimers.delete(event.teamName);
|
||||||
const current = useStore.getState();
|
const current = useStore.getState();
|
||||||
void current.refreshTeamData(event.teamName);
|
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
});
|
});
|
||||||
|
|
@ -1018,6 +1185,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
memberSpawnRefreshTimers = new Map();
|
memberSpawnRefreshTimers = new Map();
|
||||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||||
toolActivityTimers = new Map();
|
toolActivityTimers = new Map();
|
||||||
|
teamLastRelevantActivityAt.clear();
|
||||||
|
teamLastIdleWatchdogRefreshAt.clear();
|
||||||
if (teamListRefreshTimer) {
|
if (teamListRefreshTimer) {
|
||||||
clearTimeout(teamListRefreshTimer);
|
clearTimeout(teamListRefreshTimer);
|
||||||
teamListRefreshTimer = null;
|
teamListRefreshTimer = null;
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,20 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
error: 'spawn failed',
|
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.
|
* Returns dot class for a member during provisioning, respecting spawn status.
|
||||||
* Falls back to the existing `getMemberDotClass` when no spawn status is available.
|
* Falls back to the existing `getMemberDotClass` when no spawn status is available.
|
||||||
|
|
@ -115,13 +129,20 @@ export function getSpawnAwareDotClass(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState: MemberLaunchState | undefined,
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
|
runtimeAlive: boolean | undefined,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
if (isTeamAlive === false && !isTeamProvisioning) {
|
||||||
|
return STATUS_DOT_COLORS.terminated;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_DOT_COLORS.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') {
|
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
|
||||||
return SPAWN_DOT_COLORS.online;
|
return SPAWN_DOT_COLORS.online;
|
||||||
}
|
}
|
||||||
|
|
@ -153,17 +174,17 @@ export function getSpawnAwarePresenceLabel(
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
if (isTeamAlive === false && !isTeamProvisioning) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_PRESENCE_LABELS.error;
|
return SPAWN_PRESENCE_LABELS.error;
|
||||||
}
|
}
|
||||||
if (spawnStatus === 'offline' && isTeamProvisioning) {
|
|
||||||
return 'waiting for Agent';
|
|
||||||
}
|
|
||||||
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
|
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
|
||||||
return 'online';
|
return 'online';
|
||||||
}
|
}
|
||||||
if (spawnStatus === 'waiting') {
|
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
|
||||||
return SPAWN_PRESENCE_LABELS.waiting;
|
return 'starting';
|
||||||
}
|
}
|
||||||
if (spawnStatus === 'online' && livenessSource === 'process') {
|
if (spawnStatus === 'online' && livenessSource === 'process') {
|
||||||
return 'online';
|
return 'online';
|
||||||
|
|
@ -178,20 +199,31 @@ export function getSpawnAwarePresenceLabel(
|
||||||
* Card container CSS classes based on spawn status (opacity + animation).
|
* Card container CSS classes based on spawn status (opacity + animation).
|
||||||
* Used by MemberCard wrapper for fade-in transitions.
|
* 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) {
|
switch (spawnStatus) {
|
||||||
case 'offline':
|
case 'offline':
|
||||||
return 'opacity-40';
|
return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40';
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
return 'member-waiting-shimmer';
|
return 'member-waiting-shimmer';
|
||||||
case 'spawning':
|
case 'spawning':
|
||||||
return '';
|
return 'member-waiting-shimmer';
|
||||||
case 'online':
|
case 'online':
|
||||||
return 'animate-[member-fade-in_0.4s_ease-out]';
|
return 'animate-[member-fade-in_0.4s_ease-out]';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'opacity-80';
|
return 'opacity-80';
|
||||||
default:
|
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 }> = {
|
export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = {
|
||||||
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
|
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
|
||||||
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-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 () => {
|
it('omits roster context when durable teammate roster is empty', async () => {
|
||||||
mockGetMembersMeta.mockResolvedValueOnce([]);
|
mockGetMembersMeta.mockResolvedValueOnce([]);
|
||||||
service.getTeamData.mockResolvedValueOnce({
|
service.getTeamData.mockResolvedValueOnce({
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '@main/services/runtime/providerRuntimeEnv';
|
} from '@main/services/runtime/providerRuntimeEnv';
|
||||||
|
|
||||||
describe('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 = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
CLAUDE_CODE_USE_OPENAI: '1',
|
CLAUDE_CODE_USE_OPENAI: '1',
|
||||||
CLAUDE_CODE_USE_GEMINI: undefined,
|
CLAUDE_CODE_USE_GEMINI: undefined,
|
||||||
|
|
@ -16,11 +16,25 @@ describe('providerRuntimeEnv', () => {
|
||||||
|
|
||||||
const result = applyProviderRuntimeEnv(env, 'gemini');
|
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_OPENAI).toBeUndefined();
|
||||||
expect(result.CLAUDE_CODE_USE_BEDROCK).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', () => {
|
it('preserves gemini as a valid team provider id', () => {
|
||||||
expect(resolveTeamProviderId('gemini')).toBe('gemini');
|
expect(resolveTeamProviderId('gemini')).toBe('gemini');
|
||||||
expect(resolveTeamProviderId('codex')).toBe('codex');
|
expect(resolveTeamProviderId('codex')).toBe('codex');
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>()
|
||||||
const statMock = vi.fn<
|
const statMock = vi.fn<
|
||||||
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
|
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
|
||||||
>();
|
>();
|
||||||
const readdirMock = vi.fn<(filePath: PathLike) => Promise<string[]>>();
|
|
||||||
|
|
||||||
vi.mock('@main/utils/cliPathMerge', () => ({
|
vi.mock('@main/utils/cliPathMerge', () => ({
|
||||||
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
|
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
|
||||||
|
|
@ -32,14 +31,12 @@ vi.mock('fs', () => ({
|
||||||
promises: {
|
promises: {
|
||||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||||
stat: (filePath: PathLike) => statMock(filePath),
|
stat: (filePath: PathLike) => statMock(filePath),
|
||||||
readdir: (filePath: PathLike) => readdirMock(filePath),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
constants: { X_OK: 1 },
|
constants: { X_OK: 1 },
|
||||||
promises: {
|
promises: {
|
||||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||||
stat: (filePath: PathLike) => statMock(filePath),
|
stat: (filePath: PathLike) => statMock(filePath),
|
||||||
readdir: (filePath: PathLike) => readdirMock(filePath),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -55,7 +52,6 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
|
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
|
||||||
mockResolveInteractiveShellEnv.mockResolvedValue({});
|
mockResolveInteractiveShellEnv.mockResolvedValue({});
|
||||||
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
|
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
|
||||||
readdirMock.mockResolvedValue([]);
|
|
||||||
Object.defineProperty(process, 'platform', {
|
Object.defineProperty(process, 'platform', {
|
||||||
value: 'darwin',
|
value: 'darwin',
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|
@ -75,8 +71,26 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => {
|
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';
|
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) => {
|
accessMock.mockImplementation(async (filePath) => {
|
||||||
if (filePath === expectedBinary) {
|
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', () => {
|
describe('TeamDataService', () => {
|
||||||
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
|
|
@ -3103,6 +3114,47 @@ describe('TeamDataService', () => {
|
||||||
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
|
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 () => {
|
it('keeps warning order deterministic even when read failures settle out of order', async () => {
|
||||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||||
const inboxDeferred = createDeferred<string[]>();
|
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 builder = new TeamMcpConfigBuilder();
|
||||||
|
|
||||||
const configPath = await builder.writeConfigFile();
|
const configPath = await builder.writeConfigFile();
|
||||||
|
|
@ -171,14 +171,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = parsed.mcpServers?.['agent-teams'];
|
const server = parsed.mcpServers?.['agent-teams'];
|
||||||
expect(server?.command).toBe('pnpm');
|
expectNodeEntry(server, path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
|
||||||
expect(server?.args).toEqual([
|
|
||||||
'--dir',
|
|
||||||
path.join(process.cwd(), 'mcp-server'),
|
|
||||||
'exec',
|
|
||||||
'tsx',
|
|
||||||
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
|
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[] }>;
|
mcpServers: Record<string, { command?: string; args?: string[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.mcpServers['agent-teams']).toMatchObject({
|
expectNodeEntry(parsed.mcpServers['agent-teams'], path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
|
||||||
command: 'pnpm',
|
|
||||||
args: [
|
|
||||||
'--dir',
|
|
||||||
path.join(process.cwd(), 'mcp-server'),
|
|
||||||
'exec',
|
|
||||||
'tsx',
|
|
||||||
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores malformed user MCP file', async () => {
|
it('ignores malformed user MCP file', async () => {
|
||||||
|
|
@ -500,7 +484,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
|
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');
|
setPackagedMode(true, '6.0.0');
|
||||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
createdDirs.push(resourcesDir);
|
createdDirs.push(resourcesDir);
|
||||||
|
|
@ -510,15 +494,6 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
const configPath = await builder.writeConfigFile();
|
const configPath = await builder.writeConfigFile();
|
||||||
createdPaths.push(configPath);
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
expect(readGeneratedServer(configPath)).toEqual({
|
expectNodeEntry(readGeneratedServer(configPath), path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'));
|
||||||
command: 'pnpm',
|
|
||||||
args: [
|
|
||||||
'--dir',
|
|
||||||
path.join(process.cwd(), 'mcp-server'),
|
|
||||||
'exec',
|
|
||||||
'tsx',
|
|
||||||
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,74 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 * as fs from 'fs/promises';
|
||||||
|
|
||||||
import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService';
|
||||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
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', () => {
|
describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
let tmpDir: string | null = null;
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
if (tmpDir) {
|
if (tmpDir) {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|
@ -156,4 +214,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
const service = new TeamMemberRuntimeAdvisoryService();
|
const service = new TeamMemberRuntimeAdvisoryService();
|
||||||
await expect(service.getMemberAdvisory(teamName, 'alice')).resolves.toBeNull();
|
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' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
authSource: 'anthropic_api_key',
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -228,6 +229,7 @@ describe('TeamProvisioningService', () => {
|
||||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
(svc as any).restorePrelaunchConfig = restorePrelaunchConfig;
|
(svc as any).restorePrelaunchConfig = restorePrelaunchConfig;
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
||||||
|
|
@ -277,6 +279,7 @@ describe('TeamProvisioningService', () => {
|
||||||
env: { ANTHROPIC_API_KEY: 'test' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
authSource: 'anthropic_api_key',
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
(svc as any).startFilesystemMonitor = vi.fn();
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
(svc as any).stopFilesystemMonitor = vi.fn();
|
(svc as any).stopFilesystemMonitor = vi.fn();
|
||||||
|
|
@ -358,6 +361,7 @@ describe('TeamProvisioningService', () => {
|
||||||
env: { ANTHROPIC_API_KEY: 'test' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
authSource: 'anthropic_api_key',
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
await expect(
|
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).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
(svc as any).assertConfigLeadOnlyForLaunch = 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 () => ({
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
members: [{ name: 'alice', role: 'developer' }],
|
members: [{ name: 'alice', role: 'developer' }],
|
||||||
source: 'config-fallback',
|
source: 'config-fallback',
|
||||||
|
|
|
||||||
|
|
@ -54,17 +54,34 @@ function createFakeChild() {
|
||||||
return { child, writeSpy };
|
return { child, writeSpy };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPromptFromWrite(writeSpy: ReturnType<typeof vi.fn>): string {
|
function extractPromptFromBootstrapFile(callIndex = 0): string {
|
||||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
|
||||||
const parsed = JSON.parse(payload) as {
|
const promptFlagIndex = args?.indexOf('--team-bootstrap-user-prompt-file') ?? -1;
|
||||||
type: string;
|
const promptPath = promptFlagIndex >= 0 ? args?.[promptFlagIndex + 1] : null;
|
||||||
message?: { role: string; content: { type: string; text?: string }[] };
|
if (!promptPath) {
|
||||||
};
|
throw new Error('Failed to extract bootstrap prompt file path from spawn args');
|
||||||
const text = parsed.message?.content?.[0]?.text;
|
|
||||||
if (typeof text !== 'string') {
|
|
||||||
throw new Error('Failed to extract prompt text from stdin write payload');
|
|
||||||
}
|
}
|
||||||
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)', () => {
|
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');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
const { child, writeSpy } = createFakeChild();
|
const { child, writeSpy } = createFakeChild();
|
||||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
@ -98,6 +115,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
env: { ANTHROPIC_API_KEY: 'test' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
authSource: 'anthropic_api_key',
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).startFilesystemMonitor = vi.fn();
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
|
|
@ -111,31 +129,19 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
const prompt = extractPromptFromWrite(writeSpy);
|
const bootstrapSpec = extractBootstrapSpec();
|
||||||
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
|
expect(bootstrapSpec.mode).toBe('create');
|
||||||
expect(prompt).toContain('PROGRESS REPORTING (MANDATORY)');
|
expect(bootstrapSpec.team).toMatchObject({
|
||||||
expect(prompt).toContain('Never bulk-move many tasks at the end');
|
name: 'solo-team',
|
||||||
expect(prompt).toContain('Default to working ONE task at a time');
|
cwd: process.cwd(),
|
||||||
expect(prompt).toContain(
|
});
|
||||||
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
|
expect(bootstrapSpec.members).toEqual([]);
|
||||||
);
|
|
||||||
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');
|
|
||||||
|
|
||||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||||
expect(launchArgs).toContain('--mcp-config');
|
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).not.toContain('--strict-mcp-config');
|
||||||
expect(launchArgs).toContain('--disallowedTools');
|
expect(launchArgs).toContain('--disallowedTools');
|
||||||
const disallowed = launchArgs[launchArgs.indexOf('--disallowedTools') + 1] ?? '';
|
const disallowed = launchArgs[launchArgs.indexOf('--disallowedTools') + 1] ?? '';
|
||||||
|
|
@ -145,7 +151,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
await svc.cancelProvisioning(runId);
|
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.
|
// Seed config.json so launchTeam can validate team existence.
|
||||||
const teamName = 'solo-team-launch';
|
const teamName = 'solo-team-launch';
|
||||||
const teamDir = path.join(tempTeamsBase, teamName);
|
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).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
members: [],
|
members: [],
|
||||||
source: 'config-fallback',
|
source: 'config-fallback',
|
||||||
warning: undefined,
|
warning: undefined,
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
(svc as any).startFilesystemMonitor = vi.fn();
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
|
||||||
|
|
@ -189,17 +197,16 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
const prompt = extractPromptFromWrite(writeSpy);
|
const prompt = extractPromptFromBootstrapFile();
|
||||||
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
|
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('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
|
||||||
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
|
expect(prompt).toContain('Do NOT start implementation in this turn.');
|
||||||
expect(prompt).toContain('Do NOT delay this reconnect turn by reading internal config files');
|
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.');
|
||||||
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
|
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
|
'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_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||||
expect(prompt).not.toContain('teamctl.js');
|
expect(prompt).not.toContain('teamctl.js');
|
||||||
|
|
@ -212,7 +219,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
await svc.cancelProvisioning(runId);
|
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');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
const { child, writeSpy } = createFakeChild();
|
const { child, writeSpy } = createFakeChild();
|
||||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
@ -222,6 +229,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
env: { ANTHROPIC_API_KEY: 'test' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
authSource: 'anthropic_api_key',
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).startFilesystemMonitor = vi.fn();
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
|
|
@ -235,42 +243,38 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
const prompt = extractPromptFromWrite(writeSpy);
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):');
|
const bootstrapSpec = extractBootstrapSpec();
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
expect(bootstrapSpec.mode).toBe('create');
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
expect(bootstrapSpec.members).toEqual([
|
||||||
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
expect.objectContaining({
|
||||||
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
name: 'alice',
|
||||||
expect(prompt).toContain('DO: Full execution mode.');
|
role: 'developer',
|
||||||
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
|
description: 'developer',
|
||||||
expect(prompt).toContain(
|
cwd: process.cwd(),
|
||||||
'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');
|
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
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');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
const { child, writeSpy } = createFakeChild();
|
const { child, writeSpy } = createFakeChild();
|
||||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
@ -280,22 +284,33 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
env: { ANTHROPIC_API_KEY: 'test' },
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
authSource: 'anthropic_api_key',
|
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).startFilesystemMonitor = vi.fn();
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(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(),
|
cwd: process.cwd(),
|
||||||
members: [{ name: 'alice', role: 'developer' }],
|
clearContext: true,
|
||||||
description: 'Task comment forwarding live prompt test',
|
|
||||||
},
|
},
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
const prompt = extractPromptFromWrite(writeSpy);
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
|
const prompt = extractPromptFromBootstrapFile();
|
||||||
expect(prompt).toContain(
|
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);
|
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).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
members: [{ name: 'alice', role: 'developer' }],
|
members: [{ name: 'alice', role: 'developer' }],
|
||||||
source: 'config-fallback',
|
source: 'config-fallback',
|
||||||
warning: undefined,
|
warning: undefined,
|
||||||
}));
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
(svc as any).pathExists = vi.fn(async () => false);
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
(svc as any).startFilesystemMonitor = vi.fn();
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
|
||||||
|
|
@ -348,27 +365,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
const prompt = extractPromptFromWrite(writeSpy);
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
expect(prompt).toContain('The team has been reconnected after a restart.');
|
const prompt = extractPromptFromBootstrapFile();
|
||||||
expect(prompt).toContain('Restore/start the existing teammates first.');
|
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
|
||||||
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
|
expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.');
|
||||||
expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):');
|
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.');
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||||
expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
|
expect(prompt).toContain('Messages to "user" (the human) must NEVER contain agent-only blocks.');
|
||||||
expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
|
expect(prompt).toContain('task_create_from_message');
|
||||||
expect(prompt).toContain('If member_briefing fails, send a short message to your team lead');
|
expect(prompt).toContain('task_set_owner');
|
||||||
expect(prompt).toContain('After member_briefing succeeds:');
|
expect(prompt).toContain('cross_team_send');
|
||||||
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(prompt).toContain(
|
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.'
|
'review_request already notifies the reviewer'
|
||||||
);
|
|
||||||
expect(prompt).toContain(
|
|
||||||
'leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
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 {
|
import {
|
||||||
|
getLaunchAwarePresenceLabel,
|
||||||
getSpawnAwareDotClass,
|
getSpawnAwareDotClass,
|
||||||
getSpawnAwarePresenceLabel,
|
getSpawnAwarePresenceLabel,
|
||||||
|
getSpawnCardClass,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
getMemberRuntimeAdvisoryTitle,
|
getMemberRuntimeAdvisoryTitle,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -41,6 +43,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
'online',
|
'online',
|
||||||
'runtime_pending_bootstrap',
|
'runtime_pending_bootstrap',
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
|
|
@ -56,12 +59,62 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toBe('starting');
|
).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', () => {
|
it('renders unified retry advisory labels for provider retries', () => {
|
||||||
expect(
|
expect(
|
||||||
getMemberRuntimeAdvisoryLabel(
|
getMemberRuntimeAdvisoryLabel(
|
||||||
|
|
@ -86,4 +139,46 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
})
|
})
|
||||||
).toContain('capacity exceeded');
|
).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