Merge remote-tracking branch 'origin/dev' into spike/codex-native-runtime-plan

# Conflicts:
#	docs/research/codex-native-runtime-integration-decision.md
This commit is contained in:
777genius 2026-04-20 19:42:49 +03:00
commit 8093201b78
108 changed files with 11417 additions and 863 deletions

View file

@ -3,7 +3,9 @@
reviews:
auto_review:
enabled: true
drafts: false
drafts: true
base_branches:
- '.*'
auto_title_instructions: |
Follow Conventional Commits format: '<type>: <description>'
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert

View file

@ -11,8 +11,10 @@
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
getMemberRuntimeAdvisoryLabel,
resolveMemberAvatarUrl,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
@ -143,6 +145,7 @@ export class TeamGraphAdapter {
const leadId = `lead:${teamName}`;
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
const avatarMap = buildMemberAvatarMap(teamData.members);
const provisioningPresentation = buildTeamProvisioningPresentation({
progress: provisioningProgress,
members: teamData.members,
@ -158,6 +161,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
leadName,
avatarMap,
pendingApprovalAgents,
leadActivity,
leadContext,
@ -173,6 +177,7 @@ export class TeamGraphAdapter {
teamData,
teamName,
memberNodeIdByAlias,
avatarMap,
spawnStatuses,
pendingApprovalAgents,
activeTools,
@ -369,6 +374,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
leadName: string,
avatarMap: ReadonlyMap<string, string>,
pendingApprovalAgents?: Set<string>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage,
@ -428,7 +434,9 @@ export class TeamGraphAdapter {
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl(leadName, 64),
avatarUrl: leadMember
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
: agentAvatarUrl(leadName, 64),
pendingApproval,
activeTool: activeTool
? {
@ -465,6 +473,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
memberNodeIdByAlias: ReadonlyMap<string, string>,
avatarMap: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -520,7 +529,7 @@ export class TeamGraphAdapter {
spawnStatus: spawn?.status,
launchVisualState: launchPresentation.launchVisualState ?? undefined,
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
avatarUrl: agentAvatarUrl(member.name, 64),
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject

View file

@ -4,9 +4,15 @@
* composes project-specific UI, selectors, and presentation helpers.
*/
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
} from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
@ -291,7 +297,6 @@ const MemberPopoverContent = ({
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.teamName
: '';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const {
teamData,
teamMembers,
@ -301,6 +306,8 @@ const MemberPopoverContent = ({
memberSpawnSnapshot,
memberSpawnStatuses,
} = useGraphMemberPopoverContext(teamName, memberName);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const provisioningPresentation =
teamData && teamName

View file

@ -111,6 +111,7 @@ import {
} from './utils/safeWebContentsSend';
import { syncTelemetryFlag } from './sentry';
import {
ActiveTeamRegistry,
BoardTaskActivityDetailService,
BoardTaskActivityRecordSource,
BoardTaskActivityService,
@ -130,6 +131,11 @@ import {
TaskBoundaryParser,
TeamDataService,
TeamLogSourceTracker,
TeamTaskStallJournal,
TeamTaskStallMonitor,
TeamTaskStallNotifier,
TeamTaskStallPolicy,
TeamTaskStallSnapshotSource,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamProvisioningService,
@ -415,6 +421,7 @@ let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
let schedulerService: SchedulerService;
let teamTaskStallMonitor: TeamTaskStallMonitor | null = null;
let skillsWatcherService: SkillsWatcherService | null = null;
let teamBackupService: TeamBackupService | null = null;
let branchStatusService: BranchStatusService | null = null;
@ -848,6 +855,13 @@ async function initializeServices(): Promise<void> {
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
teamTaskStallMonitor = new TeamTaskStallMonitor(
new ActiveTeamRegistry(teamDataService, teamLogSourceTracker),
new TeamTaskStallSnapshotSource(),
new TeamTaskStallPolicy(),
new TeamTaskStallJournal(),
new TeamTaskStallNotifier(teamDataService)
);
let teammateToolTracker: TeammateToolTracker | null = null;
branchStatusService = new BranchStatusService((event) => {
safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event);
@ -930,6 +944,7 @@ async function initializeServices(): Promise<void> {
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
const teamChangeEmitter = (event: TeamChangeEvent): void => {
forwardTeamChange(event);
teamTaskStallMonitor?.noteTeamChange(event);
if (event.type === 'lead-activity' && event.detail === 'offline') {
teammateToolTracker?.handleTeamOffline(event.teamName);
}
@ -939,6 +954,7 @@ async function initializeServices(): Promise<void> {
teamLogSourceTracker.onLogSourceChange((teamName) => {
teammateToolTracker?.handleLogSourceChange(teamName);
});
teamTaskStallMonitor.start();
// Allow SchedulerService to push schedule events to renderer
schedulerService.setChangeEmitter((event) => {
@ -1142,6 +1158,10 @@ function shutdownServices(): void {
if (teamDataService) {
teamDataService.stopProcessHealthPolling();
}
if (teamTaskStallMonitor) {
void teamTaskStallMonitor.stop();
teamTaskStallMonitor = null;
}
branchStatusService?.dispose();
branchStatusService = null;

View file

@ -1075,6 +1075,9 @@ async function handleUpdateConfig(
}
return wrapTeamHandler('updateConfig', async () => {
const tn = validated.value!;
const teamDataService = getTeamDataService();
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
const requestedName = typeof name === 'string' ? name.trim() : '';
const result = await getTeamDataService().updateConfig(tn, {
name,
description,
@ -1085,10 +1088,10 @@ async function handleUpdateConfig(
}
// Notify running lead about the rename so it stays aware of current team name
if (typeof name === 'string' && name.trim()) {
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`;
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
try {
await provisioning.sendMessageToTeam(tn, msg);
} catch {

View file

@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
lastPressureLogAt: number;
}
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
members: readonly T[]
): T[] {
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
if (typeof value !== 'string') return '';
return value
@ -500,6 +511,27 @@ export class TeamDataService {
return this.configReader.listTeams();
}
async listAliveProcessTeams(): Promise<string[]> {
const teams = await this.listTeams();
const alive: string[] = [];
for (const team of teams) {
if (team.deletedAt) {
continue;
}
try {
const processes = await this.readProcesses(team.teamName);
if (processes.some((process) => !process.stoppedAt)) {
alive.push(team.teamName);
}
} catch {
// best-effort per team
}
}
return alive.sort((left, right) => left.localeCompare(right));
}
async getAllTasks(): Promise<GlobalTask[]> {
const rawTasks = await this.taskReader.getAllTasks();
const teams = await this.configReader.listTeams();
@ -1161,7 +1193,7 @@ export class TeamDataService {
role: configMember.role,
workflow: configMember.workflow,
agentType: configMember.agentType ?? 'general-purpose',
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
color: configMember.color,
joinedAt: configMember.joinedAt ?? Date.now(),
cwd: configMember.cwd,
};
@ -1176,13 +1208,13 @@ export class TeamDataService {
member = {
name: memberName,
agentType: 'general-purpose',
color: getMemberColorByName(memberName),
joinedAt: Date.now(),
};
}
members.push(member);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, member]);
member = nextMembers.find((m) => m.name === memberName) ?? member;
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
return { members, member };
@ -1193,6 +1225,13 @@ export class TeamDataService {
if (!name) {
throw new Error('Member name cannot be empty');
}
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
@ -1224,12 +1263,11 @@ export class TeamDataService {
? request.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(name),
joinedAt: Date.now(),
};
members.push(newMember);
await this.membersMetaStore.writeMembers(teamName, members);
const nextMembers = applyDistinctRosterColors([...members, newMember]);
await this.membersMetaStore.writeMembers(teamName, nextMembers);
}
async updateMemberRole(
@ -1269,36 +1307,50 @@ export class TeamDataService {
const joinedAt = Date.now();
const nextByName = new Set<string>();
const nextActive: TeamMember[] = request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
});
const nextActive = applyDistinctRosterColors(
request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved');
}
if (nextByName.has(name.toLowerCase())) {
throw new Error(`Member "${name}" already exists`);
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error(
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
);
}
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
const isSameActiveMember = Boolean(prev && prev.removedAt == null);
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: prev?.agentType ?? 'general-purpose',
agentId: isSameActiveMember ? prev?.agentId : undefined,
color: prev?.color,
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
})
);
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
const nextRemoved: TeamMember[] = [];
@ -1712,6 +1764,23 @@ export class TeamDataService {
return result;
}
async sendSystemNotificationToLead(args: {
teamName: string;
summary: string;
text: string;
taskRefs?: TaskRef[];
}): Promise<SendMessageResult> {
const leadName = await this.resolveLeadName(args.teamName);
return this.sendMessage(args.teamName, {
member: leadName,
from: 'system',
summary: args.summary,
text: args.text,
...(args.taskRefs && args.taskRefs.length > 0 ? { taskRefs: args.taskRefs } : {}),
source: TASK_COMMENT_NOTIFICATION_SOURCE,
});
}
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
if (!config) return 'team-lead';
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
@ -2323,12 +2392,18 @@ export class TeamDataService {
createdAt: joinedAt,
});
await this.membersMetaStore.writeMembers(
request.teamName,
const membersToWrite = applyDistinctRosterColors(
request.members.map((member) => ({
name: (() => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
const formatError = validateTeamMemberNameFormat(name);
if (formatError) {
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
}
if (name.toLowerCase() === 'user') {
throw new Error('Member name "user" is reserved');
}
if (name.toLowerCase() === 'team-lead')
throw new Error('Member name "team-lead" is reserved');
const suffixInfo = parseNumericSuffixName(name);
@ -2347,14 +2422,14 @@ export class TeamDataService {
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
agentType: 'general-purpose' as const,
joinedAt,
})),
{
providerBackendId: request.providerBackendId,
}
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
}
async reconcileTeamArtifacts(

View file

@ -22,7 +22,11 @@ interface TeamLogSourceSnapshot {
logSourceGeneration: string | null;
}
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
export type TeamLogSourceTrackingConsumer =
| 'change_presence'
| 'tool_activity'
| 'task_log_stream'
| 'stall_monitor';
interface TrackingState {
watcher: FSWatcher | null;

View file

@ -3,6 +3,7 @@ import {
createCliAutoSuffixNameGuard,
createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
@ -262,6 +263,11 @@ export class TeamMemberResolver {
}
return aStableId.localeCompare(bStableId);
});
return members;
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
}

View file

@ -1,4 +1,5 @@
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { createHash } from 'crypto';
@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import type { InboxMessage, TeamConfig } from '@shared/types';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000;
const logger = createLogger('Service:TeamMessageFeedService');
interface TeamMessageFeedDeps {
getConfig: (teamName: string) => Promise<TeamConfig | null>;
@ -18,6 +21,7 @@ interface TeamMessageFeedDeps {
interface TeamMessageFeedCacheEntry {
feedRevision: string;
messages: InboxMessage[];
cachedAt: number;
}
export interface TeamNormalizedMessageFeed {
@ -352,7 +356,10 @@ export class TeamMessageFeedService {
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
const cached = this.cacheByTeam.get(teamName);
if (cached && !this.dirtyTeams.has(teamName)) {
const now = Date.now();
const cacheDirty = this.dirtyTeams.has(teamName);
const cacheExpired = !cached || now - cached.cachedAt >= MESSAGE_FEED_CACHE_MAX_AGE_MS;
if (cached && !cacheDirty && !cacheExpired) {
return {
teamName,
feedRevision: cached.feedRevision,
@ -362,7 +369,7 @@ export class TeamMessageFeedService {
const config = await this.deps.getConfig(teamName);
if (!config) {
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
this.cacheByTeam.set(teamName, emptyEntry);
this.dirtyTeams.delete(teamName);
return { teamName, ...emptyEntry };
@ -389,12 +396,21 @@ export class TeamMessageFeedService {
});
const feedRevision = toFeedRevision(messages);
if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) {
logger.warn(
`[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages`
);
}
const nextEntry =
cached?.feedRevision === feedRevision
? cached
? {
...cached,
cachedAt: now,
}
: {
feedRevision,
messages,
cachedAt: now,
};
this.cacheByTeam.set(teamName, nextEntry);

View file

@ -38,6 +38,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
import {
@ -225,6 +227,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
function applyDistinctProvisioningMemberColors<
T extends { name: string; color?: string; removedAt?: number },
>(members: readonly T[]): T[] {
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
return members.map((member) => ({
...member,
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
}));
}
const FS_MONITOR_POLL_MS = 2000;
const TASK_WAIT_FALLBACK_MS = 15_000;
const STALL_CHECK_INTERVAL_MS = 10_000;
@ -733,6 +745,8 @@ interface ProvisioningRun {
>;
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
memberSpawnToolUseIds: Map<string, string>;
/** Explicit restart requests awaiting teammate rejoin or failure. */
pendingMemberRestarts: Map<string, PendingMemberRestartContext>;
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
/** Highest accepted deterministic bootstrap event sequence for this run. */
@ -827,6 +841,78 @@ interface LiveTeamAgentRuntimeMetadata {
tmuxPaneId?: string;
}
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function commandContainsCliArgValue(command: string, argName: string, value: string): boolean {
const normalizedCommand = command.trim();
const normalizedValue = value.trim();
if (!normalizedCommand || !normalizedValue) {
return false;
}
const pattern = new RegExp(
`(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)`
);
return pattern.test(normalizedCommand);
}
function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
return reason?.trim() === 'Teammate was never spawned during launch.';
}
function isLaunchGraceWindowFailureReason(reason?: string): boolean {
return reason?.trim() === 'Teammate did not join within the launch grace window.';
}
function isConfigRegistrationFailureReason(reason?: string): boolean {
return (
reason?.trim() ===
'Teammate was not registered in config.json during launch. Persistent spawn failed.'
);
}
function isTmuxNoServerRunningError(error: unknown): boolean {
const text = error instanceof Error ? error.message : String(error ?? '');
return /no server running on /i.test(text);
}
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
return (
isNeverSpawnedDuringLaunchReason(reason) ||
isLaunchGraceWindowFailureReason(reason) ||
isConfigRegistrationFailureReason(reason)
);
}
function buildRestartStillRunningReason(memberName: string): string {
return (
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
`to be active. The requested settings may not have been applied.`
);
}
function buildRestartDuplicateUnconfirmedReason(memberName: string, rawReason?: string): string {
const suffix = rawReason?.trim()
? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".`
: ' Agent returned duplicate_skipped without a reason.';
return (
`Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix
);
}
function buildRestartGraceTimeoutReason(memberName: string): string {
return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
}
interface PendingMemberRestartContext {
requestedAt: string;
desired: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
>;
}
function normalizeTeamAgentRuntimeBackendType(
value: string | undefined,
isLead: boolean
@ -1000,19 +1086,60 @@ function sleep(ms: number): Promise<void> {
async function waitForPidsToExit(
pids: readonly number[],
opts: { timeoutMs: number; pollMs: number }
): Promise<void> {
): Promise<number[]> {
if (pids.length === 0) {
return;
return [];
}
const deadline = Date.now() + opts.timeoutMs;
let remainingPids = [...new Set(pids)];
while (Date.now() < deadline) {
const remaining = pids.filter((pid) => isProcessAlive(pid));
if (remaining.length === 0) {
return;
remainingPids = remainingPids.filter((pid) => isProcessAlive(pid));
if (remainingPids.length === 0) {
return [];
}
await sleep(opts.pollMs);
}
return remainingPids;
}
async function waitForTmuxPanesToExit(
paneIds: readonly string[],
opts: { timeoutMs: number; pollMs: number }
): Promise<string[]> {
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
if (normalizedPaneIds.length === 0) {
return [];
}
const deadline = Date.now() + opts.timeoutMs;
let remainingPaneIds = normalizedPaneIds;
let lastError: unknown = null;
while (Date.now() < deadline) {
let livePanePidById: Map<string, number>;
try {
livePanePidById = await listTmuxPanePidsForCurrentPlatform(remainingPaneIds);
lastError = null;
} catch (error) {
if (isTmuxNoServerRunningError(error)) {
return [];
}
lastError = error;
await sleep(opts.pollMs);
continue;
}
remainingPaneIds = remainingPaneIds.filter((paneId) => livePanePidById.has(paneId));
if (remainingPaneIds.length === 0) {
return [];
}
await sleep(opts.pollMs);
}
if (lastError) {
throw lastError instanceof Error ? lastError : new Error(getErrorMessage(lastError));
}
return remainingPaneIds;
}
async function waitForChildProcessToExit(
@ -1646,7 +1773,9 @@ export function buildRestartMemberSpawnMessage(
return (
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` +
`This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` +
`This is a restart of an existing persistent teammate, not a new teammate. ` +
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
indentMultiline(prompt, ' ')
);
}
@ -3937,6 +4066,7 @@ export class TeamProvisioningService {
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
if (spawnedMemberName) {
run.memberSpawnToolUseIds.delete(toolUseId);
const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName);
if (isError) {
const resultPreview = extractToolResultPreview(resultContent);
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
@ -3946,8 +4076,42 @@ export class TeamProvisioningService {
const detail =
parsedStatus.reason === 'already_running'
? 'duplicate spawn skipped - already running'
: 'duplicate spawn skipped - teammate already online';
: parsedStatus.reason === 'bootstrap_pending'
? 'duplicate spawn skipped - teammate bootstrap still pending'
: parsedStatus.rawReason
? `duplicate spawn skipped - unrecognized reason: ${parsedStatus.rawReason}`
: 'duplicate spawn skipped - reason unavailable';
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
if (pendingRestart && !parsedStatus.reason) {
logger.warn(
`[${run.teamName}] Restart for teammate "${spawnedMemberName}" returned duplicate_skipped without a recognized reason`
);
run.pendingMemberRestarts.delete(spawnedMemberName);
this.setMemberSpawnStatus(
run,
spawnedMemberName,
'error',
buildRestartDuplicateUnconfirmedReason(spawnedMemberName, parsedStatus.rawReason)
);
return;
}
if (parsedStatus.reason === 'already_running') {
if (pendingRestart) {
run.pendingMemberRestarts.delete(spawnedMemberName);
this.setMemberSpawnStatus(
run,
spawnedMemberName,
'error',
buildRestartStillRunningReason(spawnedMemberName)
);
return;
}
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
} else {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
}
return;
}
@ -3965,11 +4129,16 @@ export class TeamProvisioningService {
memberName: string,
resultPreview?: string
): void {
const pendingRestart = run.pendingMemberRestarts.get(memberName);
const reason =
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
? resultPreview.trim()
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
const message = `Teammate "${memberName}" failed to start: ${reason}`;
const message = pendingRestart
? `Failed to restart teammate "${memberName}": ${reason}`
: `Teammate "${memberName}" failed to start: ${reason}`;
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(run, memberName, 'error', message);
@ -4022,6 +4191,23 @@ export class TeamProvisioningService {
}
}
private clearMemberSpawnToolTracking(run: ProvisioningRun, memberName: string): void {
let removed = false;
for (const [toolUseId, trackedMemberName] of run.memberSpawnToolUseIds.entries()) {
if (trackedMemberName !== memberName) continue;
run.memberSpawnToolUseIds.delete(toolUseId);
removed = true;
}
if (removed) {
this.appendMemberBootstrapDiagnostic(
run,
memberName,
'cleared stale spawn tool tracking before manual restart'
);
}
}
/**
* Update spawn status for a specific team member and emit a change event.
*/
@ -4034,6 +4220,21 @@ export class TeamProvisioningService {
heartbeatAt?: string
): void {
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
if (
status === 'waiting' &&
!prev.hardFailure &&
(prev.bootstrapConfirmed || prev.runtimeAlive)
) {
this.setMemberSpawnStatus(
run,
memberName,
'online',
undefined,
prev.livenessSource,
prev.lastHeartbeatAt
);
return;
}
const updatedAt = nowIso();
const next: MemberSpawnStatusEntry = {
...prev,
@ -4042,13 +4243,26 @@ export class TeamProvisioningService {
};
if (status === 'spawning') {
next.launchState = 'starting';
} else if (status === 'waiting') {
next.agentToolAccepted = true;
next.agentToolAccepted = false;
next.runtimeAlive = false;
next.bootstrapConfirmed = false;
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.livenessSource = undefined;
next.firstSpawnAcceptedAt = undefined;
next.lastHeartbeatAt = undefined;
next.launchState = 'starting';
} else if (status === 'waiting') {
next.agentToolAccepted = true;
next.runtimeAlive = false;
next.bootstrapConfirmed = false;
next.hardFailure = false;
next.error = undefined;
next.hardFailureReason = undefined;
next.livenessSource = undefined;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
next.lastHeartbeatAt = undefined;
next.launchState = 'runtime_pending_bootstrap';
} else if (status === 'online') {
next.agentToolAccepted = true;
@ -4076,6 +4290,11 @@ export class TeamProvisioningService {
next.launchState = 'failed_to_start';
} else if (status === 'offline') {
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
next.error = undefined;
next.hardFailureReason = undefined;
next.livenessSource = undefined;
next.firstSpawnAcceptedAt = undefined;
next.lastHeartbeatAt = undefined;
}
next.launchState = deriveMemberLaunchState(next);
@ -4096,6 +4315,13 @@ export class TeamProvisioningService {
}
run.memberSpawnStatuses.set(memberName, next);
if (
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
status === 'offline' ||
status === 'error'
) {
run.pendingMemberRestarts?.delete(memberName);
}
this.syncMemberLaunchGraceCheck(run, memberName, next);
if (status === 'spawning') {
@ -4334,11 +4560,38 @@ export class TeamProvisioningService {
throw new Error(`Team "${teamName}" is not currently running`);
}
const config = await this.configReader.getConfig(teamName);
const configuredMembers = config?.members ?? [];
const configuredMember = configuredMembers.find(
(member) => member?.name?.trim() === memberName
);
const readCurrentConfiguredMember = async (): Promise<{
config: TeamConfig | null;
configuredMembers: TeamConfig['members'];
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>;
}> => {
const config = await this.configReader.getConfig(teamName);
const configuredMembers = config?.members ?? [];
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
try {
metaMembers = await this.membersMetaStore.getMembers(teamName);
} catch {
metaMembers = [];
}
return {
config,
configuredMembers,
metaMembers,
configuredMember: this.resolveEffectiveConfiguredMember(
configuredMembers,
metaMembers,
memberName
),
};
};
let { config, configuredMembers, metaMembers, configuredMember } =
await readCurrentConfiguredMember();
if (!config) {
throw new Error(`Team "${teamName}" configuration is no longer available`);
}
if (!configuredMember) {
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
}
@ -4348,6 +4601,9 @@ export class TeamProvisioningService {
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
throw new Error('Lead restart is not supported from member controls');
}
if (run.pendingMemberRestarts.has(memberName)) {
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
}
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
@ -4365,6 +4621,8 @@ export class TeamProvisioningService {
);
}
this.agentRuntimeSnapshotCache.delete(teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(teamName);
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const livePids = new Set<number>();
let hasAliveRuntimeWithoutPid = false;
@ -4387,6 +4645,7 @@ export class TeamProvisioningService {
);
}
const tmuxPaneIdsToVerify: string[] = [];
for (const persistedRuntimeMember of persistedRuntimeMembers) {
const paneId =
typeof persistedRuntimeMember.tmuxPaneId === 'string'
@ -4396,6 +4655,7 @@ export class TeamProvisioningService {
if (!paneId || backendType !== 'tmux') {
continue;
}
tmuxPaneIdsToVerify.push(paneId);
try {
killTmuxPaneForCurrentPlatformSync(paneId);
logger.info(
@ -4423,26 +4683,94 @@ export class TeamProvisioningService {
}
if (livePids.size > 0) {
await waitForPidsToExit(Array.from(livePids), {
const lingeringPids = await waitForPidsToExit(Array.from(livePids), {
timeoutMs: 1_500,
pollMs: 100,
});
if (lingeringPids.length > 0) {
throw new Error(
`Restart for teammate "${memberName}" is still waiting for the previous process to exit (${lingeringPids.join(', ')}).`
);
}
}
if (tmuxPaneIdsToVerify.length > 0) {
let lingeringPaneIds: string[];
try {
lingeringPaneIds = await waitForTmuxPanesToExit(tmuxPaneIdsToVerify, {
timeoutMs: 1_500,
pollMs: 100,
});
} catch (error) {
throw new Error(
`Restart for teammate "${memberName}" could not verify that the previous tmux pane exited: ${
error instanceof Error ? error.message : String(error)
}`
);
}
if (lingeringPaneIds.length > 0) {
throw new Error(
`Restart for teammate "${memberName}" is still waiting for the previous tmux pane to exit (${lingeringPaneIds.join(', ')}).`
);
}
}
this.setMemberSpawnStatus(run, memberName, 'offline');
const latestRunId = this.getAliveRunId(teamName);
const currentRun = this.runs.get(runId);
if (
latestRunId !== runId ||
!currentRun ||
currentRun !== run ||
currentRun.processKilled ||
currentRun.cancelRequested
) {
throw new Error(`Team "${teamName}" is not currently running`);
}
({ config, configuredMembers, metaMembers, configuredMember } =
await readCurrentConfiguredMember());
if (!config) {
throw new Error(`Team "${teamName}" configuration disappeared while restart was in progress`);
}
if (!configuredMember) {
throw new Error(
`Member "${memberName}" is no longer configured in team "${teamName}" after restart preparation`
);
}
if (configuredMember.removedAt) {
throw new Error(`Member "${memberName}" was removed while restart was in progress`);
}
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
throw new Error('Lead restart is not supported from member controls');
}
this.agentRuntimeSnapshotCache.delete(teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(teamName);
this.setMemberSpawnStatus(run, memberName, 'offline');
this.resetRuntimeToolActivity(run, memberName);
this.clearMemberSpawnToolTracking(run, memberName);
this.setMemberSpawnStatus(run, memberName, 'spawning');
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
run.pendingMemberRestarts.set(memberName, {
requestedAt: nowIso(),
desired: {
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
},
});
const leadName =
configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead';
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
const restartMessage = buildRestartMemberSpawnMessage(
teamName,
config?.name?.trim() || teamName,
leadName,
{
name: memberName,
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
providerId: configuredMember.providerId,
@ -4454,6 +4782,7 @@ export class TeamProvisioningService {
try {
await this.sendMessageToRun(run, restartMessage);
} catch (error) {
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(
run,
memberName,
@ -4483,6 +4812,10 @@ export class TeamProvisioningService {
return;
}
if (!entry.firstSpawnAcceptedAt) {
if (existing) {
clearTimeout(existing);
this.pendingTimeouts.delete(key);
}
return;
}
const remainingMs =
@ -4530,11 +4863,17 @@ export class TeamProvisioningService {
) {
return;
}
const restartPending = run.pendingMemberRestarts.has(memberName);
if (restartPending) {
run.pendingMemberRestarts.delete(memberName);
}
this.setMemberSpawnStatus(
run,
memberName,
'error',
'Teammate did not join within the launch grace window.'
restartPending
? buildRestartGraceTimeoutReason(memberName)
: 'Teammate did not join within the launch grace window.'
);
}
@ -5954,6 +6293,7 @@ export class TeamProvisioningService {
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
pendingMemberRestarts: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
@ -6074,8 +6414,7 @@ export class TeamProvisioningService {
limitContext: request.limitContext,
createdAt: Date.now(),
});
await this.membersMetaStore.writeMembers(
request.teamName,
const membersToWrite = applyDistinctProvisioningMemberColors(
effectiveMemberSpecs.map((m) => ({
name: m.name.trim(),
role: m.role?.trim() || undefined,
@ -6087,13 +6426,13 @@ export class TeamProvisioningService {
? m.effort
: undefined,
agentType: 'general-purpose' as const,
color: getMemberColorByName(m.name.trim()),
joinedAt: Date.now(),
})),
{
providerBackendId: request.providerBackendId,
}
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
if (request.skipPermissions === false) {
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
}
@ -6550,6 +6889,7 @@ export class TeamProvisioningService {
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
pendingMemberRestarts: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
@ -8053,13 +8393,29 @@ export class TeamProvisioningService {
const nextStatuses = { ...statuses };
for (const [memberName, metadata] of runtimeByMember.entries()) {
const current = nextStatuses[memberName];
if (!current || !metadata.model) {
if (!current) {
continue;
}
nextStatuses[memberName] = {
const nextEntry: MemberSpawnStatusEntry = {
...current,
runtimeModel: metadata.model,
...(metadata.model ? { runtimeModel: metadata.model } : {}),
};
const failureReason = current.hardFailureReason ?? current.error;
if (
metadata.alive &&
current.launchState === 'failed_to_start' &&
isAutoClearableLaunchFailureReason(failureReason)
) {
nextEntry.status = 'online';
nextEntry.agentToolAccepted = true;
nextEntry.runtimeAlive = true;
nextEntry.hardFailure = false;
nextEntry.hardFailureReason = undefined;
nextEntry.error = undefined;
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
nextStatuses[memberName] = nextEntry;
}
return nextStatuses;
}
@ -8107,6 +8463,87 @@ export class TeamProvisioningService {
return undefined;
}
private resolveEffectiveConfiguredMember(
configuredMembers: TeamConfig['members'] | undefined,
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>,
memberName: string
): {
name: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
agentType?: string;
removedAt?: number | string;
} | null {
const configuredMember = (configuredMembers ?? []).find((member) => {
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
});
const metaMember = metaMembers.find((member) => {
const candidateName = member.name?.trim() ?? '';
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
});
if (!configuredMember && !metaMember) {
return null;
}
const name =
metaMember?.name?.trim() || configuredMember?.name?.trim() || memberName.trim() || memberName;
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
const workflow =
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
const providerId =
normalizeTeamMemberProviderId(metaMember?.providerId) ??
normalizeTeamMemberProviderId(configuredMember?.providerId);
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
const effort =
metaMember?.effort === 'low' ||
metaMember?.effort === 'medium' ||
metaMember?.effort === 'high'
? metaMember.effort
: configuredMember?.effort === 'low' ||
configuredMember?.effort === 'medium' ||
configuredMember?.effort === 'high'
? configuredMember.effort
: undefined;
const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
return {
name,
...(role ? { role } : {}),
...(workflow ? { workflow } : {}),
...(providerId ? { providerId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(agentType ? { agentType } : {}),
...(removedAt != null ? { removedAt } : {}),
};
}
private resolveLeadMemberName(
configuredMembers: TeamConfig['members'] | undefined,
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>
): string {
const configuredLead = (configuredMembers ?? []).find((member) => isLeadMember(member));
const configuredLeadName = configuredLead?.name?.trim();
if (configuredLeadName) {
return configuredLeadName;
}
const metaLead = metaMembers.find((member) => isLeadMember(member));
const metaLeadName = metaLead?.name?.trim();
if (metaLeadName) {
return metaLeadName;
}
return 'team-lead';
}
private findEffectiveRunMemberModel(
run: ProvisioningRun | null,
memberName: string
@ -8222,6 +8659,23 @@ export class TeamProvisioningService {
});
}
for (const member of metaMembers) {
const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) {
continue;
}
const runtimeModel =
member.model?.trim() ||
this.findConfiguredMemberModel(configuredMembers, memberName) ||
this.findEffectiveRunMemberModel(run, memberName);
upsertMetadata(memberName, {
...(runtimeModel ? { model: runtimeModel } : {}),
...(typeof member.agentId === 'string' && member.agentId.trim()
? { agentId: member.agentId.trim() }
: {}),
});
}
for (const member of run?.effectiveMembers ?? []) {
const memberName = member.name?.trim() ?? '';
if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') {
@ -8248,21 +8702,38 @@ export class TeamProvisioningService {
}
}
const unresolvedAgentIds = [...metadataByMember.values()]
.map((metadata) => metadata.agentId?.trim() ?? '')
.filter((agentId) => agentId.length > 0);
const processPidByAgentId =
unresolvedAgentIds.length > 0
? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds)
: new Map<string, number>();
for (const [memberName, metadata] of metadataByMember.entries()) {
const paneId = metadata.tmuxPaneId?.trim() ?? '';
const backendType = metadata.backendType;
const panePid = paneId ? panePidById.get(paneId) : undefined;
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const alive =
const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined;
const resolvedPid =
typeof panePid === 'number' && panePid > 0
? panePid
: typeof processPid === 'number' && processPid > 0
? processPid
: undefined;
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const mayInferAliveFromStatusOnly = status?.launchState !== 'failed_to_start';
const alive =
typeof resolvedPid === 'number' && resolvedPid > 0
? true
: backendType === 'tmux'
? false
: Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
: mayInferAliveFromStatusOnly &&
Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
metadataByMember.set(memberName, {
...metadata,
alive,
...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}),
...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}),
});
}
@ -8310,6 +8781,46 @@ export class TeamProvisioningService {
return rows;
}
private findLiveProcessPidByAgentId(
teamName: string,
agentIds: readonly string[]
): Map<string, number> {
const normalizedAgentIds = [
...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)),
];
if (normalizedAgentIds.length === 0) {
return new Map();
}
const rows = this.readUnixProcessTableRows();
if (rows.length === 0) {
return new Map();
}
const pidByAgentId = new Map<string, number>();
for (const row of rows) {
if (
!commandContainsCliArgValue(row.command, '--team-name', teamName) ||
!row.command.includes('--agent-id')
) {
continue;
}
for (const agentId of normalizedAgentIds) {
if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) {
continue;
}
const currentPid = pidByAgentId.get(agentId);
if (!currentPid || row.pid > currentPid) {
pidByAgentId.set(agentId, row.pid);
}
break;
}
}
return pidByAgentId;
}
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
if (uniquePids.length === 0) {
@ -8518,6 +9029,7 @@ export class TeamProvisioningService {
const nextMembers = { ...persisted.members };
const now = nowIso();
for (const expected of persisted.expectedMembers) {
const bootstrapMember = bootstrapSnapshot?.members[expected];
const current = nextMembers[expected] ?? {
name: expected,
launchState: 'starting',
@ -8527,6 +9039,20 @@ export class TeamProvisioningService {
hardFailure: false,
lastEvaluatedAt: now,
};
if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) {
current.agentToolAccepted = true;
current.firstSpawnAcceptedAt =
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
}
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
current.runtimeAlive = true;
current.lastRuntimeAliveAt =
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
}
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
}
const matchedRuntimeNames = [...configMembers].filter((name) => {
if (name === expected) return true;
const parsed = parseNumericSuffixName(name);
@ -8573,6 +9099,21 @@ export class TeamProvisioningService {
: current.sources?.configDrift,
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
};
const bootstrapProvesSpawnAcceptance =
bootstrapMember?.agentToolAccepted === true ||
typeof bootstrapMember?.firstSpawnAcceptedAt === 'string';
const currentProvesSpawnAcceptance =
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
if (
isNeverSpawnedDuringLaunchReason(current.hardFailureReason) &&
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
) {
current.hardFailure = false;
current.hardFailureReason = undefined;
if (current.sources) {
current.sources.hardFailureSignal = undefined;
}
}
if (heartbeatReason) {
current.hardFailure = true;
current.hardFailureReason = heartbeatReason;
@ -9405,6 +9946,16 @@ export class TeamProvisioningService {
}
if (outcome === 'already_running') {
if (run.pendingMemberRestarts.has(memberName)) {
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(
run,
memberName,
'error',
buildRestartStillRunningReason(memberName)
);
return true;
}
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
return true;
}
@ -13117,8 +13668,7 @@ export class TeamProvisioningService {
const joinedAt = Date.now();
try {
await this.membersMetaStore.writeMembers(
teamName,
const membersToWrite = applyDistinctProvisioningMemberColors(
teammateMembers.map((member) => ({
name: member.name.trim(),
role: member.role?.trim() || undefined,
@ -13129,14 +13679,14 @@ export class TeamProvisioningService {
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
agentType: 'general-purpose' as const,
joinedAt,
})),
{
providerBackendId: request.providerBackendId,
}
);
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
} catch (error) {
logger.warn(
`[${teamName}] Failed to persist members.meta.json: ${

View file

@ -39,3 +39,12 @@ export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';
export { countLineChanges } from './UnifiedLineCounter';
export { ActiveTeamRegistry } from './stallMonitor/ActiveTeamRegistry';
export { BoardTaskActivityBatchIndexer } from './stallMonitor/BoardTaskActivityBatchIndexer';
export { TeamTaskLogFreshnessReader } from './stallMonitor/TeamTaskLogFreshnessReader';
export { TeamTaskStallExactRowReader } from './stallMonitor/TeamTaskStallExactRowReader';
export { TeamTaskStallJournal } from './stallMonitor/TeamTaskStallJournal';
export { TeamTaskStallMonitor } from './stallMonitor/TeamTaskStallMonitor';
export { TeamTaskStallNotifier } from './stallMonitor/TeamTaskStallNotifier';
export { TeamTaskStallPolicy } from './stallMonitor/TeamTaskStallPolicy';
export { TeamTaskStallSnapshotSource } from './stallMonitor/TeamTaskStallSnapshotSource';

View file

@ -0,0 +1,101 @@
import type { TeamLogSourceTracker } from '../TeamLogSourceTracker';
import type { TeamChangeEvent } from '@shared/types';
interface TeamAliveProcessesReader {
listAliveProcessTeams(): Promise<string[]>;
}
interface TeamLogSourceTrackingHandle {
enableTracking(
teamName: string,
consumer: 'stall_monitor'
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
disableTracking(
teamName: string,
consumer: 'stall_monitor'
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
}
export class ActiveTeamRegistry {
private readonly activeTeams = new Set<string>();
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly teamDataService: TeamAliveProcessesReader,
private readonly teamLogSourceTracker: Pick<
TeamLogSourceTracker,
'enableTracking' | 'disableTracking'
> &
TeamLogSourceTrackingHandle,
private readonly reconcileIntervalMs: number = 5 * 60_000
) {}
noteTeamChange(event: TeamChangeEvent): void {
if (
event.type === 'member-spawn' ||
(event.type === 'lead-activity' && event.detail !== 'offline')
) {
if (!this.activeTeams.has(event.teamName)) {
this.activeTeams.add(event.teamName);
void this.teamLogSourceTracker.enableTracking(event.teamName, 'stall_monitor');
}
return;
}
if (event.type === 'task-log-change' || event.type === 'log-source-change') {
if (!this.activeTeams.has(event.teamName)) {
return;
}
}
}
async listActiveTeams(): Promise<string[]> {
return [...this.activeTeams].sort((left, right) => left.localeCompare(right));
}
start(): void {
if (this.reconcileTimer) {
return;
}
void this.reconcile();
this.reconcileTimer = setInterval(() => {
void this.reconcile();
}, this.reconcileIntervalMs);
}
async stop(): Promise<void> {
if (this.reconcileTimer) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
}
const teamNames = [...this.activeTeams];
this.activeTeams.clear();
await Promise.all(
teamNames.map((teamName) =>
this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor')
)
);
}
async reconcile(): Promise<void> {
const aliveTeams = await this.teamDataService.listAliveProcessTeams();
const aliveSet = new Set(aliveTeams);
for (const teamName of aliveTeams) {
if (this.activeTeams.has(teamName)) {
continue;
}
this.activeTeams.add(teamName);
await this.teamLogSourceTracker.enableTracking(teamName, 'stall_monitor');
}
for (const teamName of [...this.activeTeams]) {
if (aliveSet.has(teamName)) {
continue;
}
this.activeTeams.delete(teamName);
await this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor');
}
}
}

View file

@ -0,0 +1,30 @@
import { BoardTaskActivityRecordBuilder } from '../taskLogs/activity/BoardTaskActivityRecordBuilder';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { RawTaskActivityMessage } from '../taskLogs/activity/BoardTaskActivityTranscriptReader';
import type { TeamTask } from '@shared/types';
export class BoardTaskActivityBatchIndexer {
constructor(
private readonly recordBuilder: Pick<
BoardTaskActivityRecordBuilder,
'buildForTasks'
> = new BoardTaskActivityRecordBuilder()
) {}
buildIndex(args: {
teamName: string;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): Map<string, BoardTaskActivityRecord[]> {
if (args.tasks.length === 0 || args.messages.length === 0) {
return new Map();
}
return this.recordBuilder.buildForTasks({
teamName: args.teamName,
tasks: args.tasks,
messages: args.messages,
});
}
}

View file

@ -0,0 +1,124 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache';
import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
interface ParsedFreshnessSignal {
taskId: string;
updatedAt: string;
transcriptFileBasename?: string;
}
function encodeTaskId(taskId: string): string {
return encodeURIComponent(taskId);
}
function isValidTimestamp(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
}
export class TeamTaskLogFreshnessReader {
private readonly cache = new BoardTaskActivityParseCache<ParsedFreshnessSignal | false>();
async readSignals(
projectDir: string,
taskIds: string[]
): Promise<Map<string, TaskLogFreshnessSignal>> {
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
const signalFilePaths = uniqueTaskIds.map((taskId) =>
path.join(
projectDir,
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
`${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`
)
);
this.cache.retainOnly(new Set(signalFilePaths));
const rows = await Promise.all(
uniqueTaskIds.map(async (taskId, index) => {
const filePath = signalFilePaths[index];
const parsed = await this.readSignal(filePath);
if (!parsed || parsed.taskId !== taskId) {
return null;
}
return [
taskId,
{
taskId,
updatedAt: parsed.updatedAt,
filePath,
...(parsed.transcriptFileBasename
? { transcriptFileBasename: parsed.transcriptFileBasename }
: {}),
} satisfies TaskLogFreshnessSignal,
] as const;
})
);
return new Map(rows.filter((row): row is NonNullable<typeof row> => row !== null));
}
private async readSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
this.cache.clearForPath(filePath);
return false;
}
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached !== null) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.parseSignal(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch {
this.cache.clearForPath(filePath);
return false;
}
}
private async parseSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return false;
}
const record = parsed as Record<string, unknown>;
const taskId =
typeof record.taskId === 'string' && record.taskId.trim().length > 0
? record.taskId.trim()
: null;
const updatedAt = isValidTimestamp(record.updatedAt) ? record.updatedAt : null;
if (!taskId || !updatedAt) {
return false;
}
return {
taskId,
updatedAt,
...(typeof record.transcriptFile === 'string' && record.transcriptFile.trim().length > 0
? { transcriptFileBasename: path.basename(record.transcriptFile.trim()) }
: {}),
};
}
}

View file

@ -0,0 +1,127 @@
import { yieldToEventLoop } from '@main/utils/asyncYield';
import { parseJsonlLine } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as readline from 'readline';
import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache';
import type { TeamTaskStallExactRow } from './TeamTaskStallTypes';
const logger = createLogger('Service:TeamTaskStallExactRowReader');
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function hasStrictTimestamp(record: Record<string, unknown>): boolean {
return typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp));
}
function parseSystemSubtype(record: Record<string, unknown>): 'turn_duration' | 'init' | undefined {
return record.subtype === 'turn_duration' || record.subtype === 'init'
? record.subtype
: undefined;
}
export class TeamTaskStallExactRowReader {
private readonly cache = new BoardTaskActivityParseCache<TeamTaskStallExactRow[]>();
async parseFiles(filePaths: string[]): Promise<Map<string, TeamTaskStallExactRow[]>> {
const uniquePaths = [...new Set(filePaths)].sort();
this.cache.retainOnly(new Set(uniquePaths));
const rows = await Promise.all(
uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const)
);
return new Map(rows);
}
private async parseFile(filePath: string): Promise<TeamTaskStallExactRow[]> {
try {
const stat = await fs.stat(filePath);
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached !== null) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.readFile(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch (error) {
logger.debug(`Skipping unreadable stall exact-log transcript ${filePath}: ${String(error)}`);
this.cache.clearForPath(filePath);
return [];
}
}
private async readFile(filePath: string): Promise<TeamTaskStallExactRow[]> {
const rows: TeamTaskStallExactRow[] = [];
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let lineCount = 0;
let sourceOrder = 0;
for await (const line of rl) {
if (!line.trim()) {
continue;
}
lineCount += 1;
try {
const raw = JSON.parse(line) as unknown;
const record = asRecord(raw);
if (!record || !hasStrictTimestamp(record)) {
continue;
}
const parsed = parseJsonlLine(line);
if (!parsed) {
continue;
}
sourceOrder += 1;
const systemSubtype = parseSystemSubtype(record);
rows.push({
filePath,
sourceOrder,
messageUuid: parsed.uuid,
timestamp: record.timestamp as string,
parsedMessage: parsed,
...(parsed.requestId ? { requestId: parsed.requestId } : {}),
...(parsed.sourceToolUseID ? { sourceToolUseId: parsed.sourceToolUseID } : {}),
...(parsed.sourceToolAssistantUUID
? { sourceToolAssistantUuid: parsed.sourceToolAssistantUUID }
: {}),
...(systemSubtype ? { systemSubtype } : {}),
toolUseIds: parsed.toolCalls.map((toolCall) => toolCall.id),
toolResultIds: parsed.toolResults.map((toolResult) => toolResult.toolUseId),
});
} catch (error) {
logger.debug(`Skipping malformed stall exact-log line in ${filePath}: ${String(error)}`);
}
if (lineCount % 250 === 0) {
await yieldToEventLoop();
}
}
return rows;
}
}

View file

@ -0,0 +1,145 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from '../atomicWrite';
import { withFileLock } from '../fileLock';
import type {
TaskStallEvaluation,
TaskStallJournalEntry,
TaskStallJournalState,
} from './TeamTaskStallTypes';
function isValidState(value: unknown): value is TaskStallJournalState {
return value === 'suspected' || value === 'alert_ready' || value === 'alerted';
}
export class TeamTaskStallJournal {
private getFilePath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'stall-monitor-journal.json');
}
async reconcileScan(args: {
teamName: string;
evaluations: TaskStallEvaluation[];
activeTaskIds: string[];
now: string;
}): Promise<TaskStallEvaluation[]> {
const filePath = this.getFilePath(args.teamName);
let readyEvaluations: TaskStallEvaluation[] = [];
await withFileLock(filePath, async () => {
const entries = await this.readUnlocked(filePath);
const candidateByEpoch = new Map(
args.evaluations
.filter(
(
evaluation
): evaluation is TaskStallEvaluation &
Required<Pick<TaskStallEvaluation, 'taskId' | 'branch' | 'signal' | 'epochKey'>> =>
evaluation.status === 'alert' &&
typeof evaluation.taskId === 'string' &&
typeof evaluation.branch === 'string' &&
typeof evaluation.signal === 'string' &&
typeof evaluation.epochKey === 'string'
)
.map((evaluation) => [evaluation.epochKey, evaluation] as const)
);
const activeTaskIdSet = new Set(args.activeTaskIds);
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) {
entries.splice(i, 1);
}
}
for (const [epochKey, evaluation] of candidateByEpoch) {
const existing = entries.find((entry) => entry.epochKey === epochKey);
if (!existing) {
entries.push({
epochKey,
teamName: args.teamName,
taskId: evaluation.taskId,
branch: evaluation.branch,
signal: evaluation.signal,
state: 'suspected',
consecutiveScans: 1,
createdAt: args.now,
updatedAt: args.now,
});
continue;
}
existing.updatedAt = args.now;
if (existing.state === 'alerted') {
continue;
}
existing.consecutiveScans += 1;
if (existing.consecutiveScans >= 2) {
existing.state = 'alert_ready';
readyEvaluations.push(evaluation);
}
}
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
});
return readyEvaluations;
}
async markAlerted(teamName: string, epochKey: string, now: string): Promise<void> {
const filePath = this.getFilePath(teamName);
await withFileLock(filePath, async () => {
const entries = await this.readUnlocked(filePath);
const target = entries.find((entry) => entry.epochKey === epochKey);
if (!target) {
return;
}
target.state = 'alerted';
target.updatedAt = now;
target.alertedAt = now;
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
});
}
private async readUnlocked(filePath: string): Promise<TaskStallJournalEntry[]> {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(
(item): item is TaskStallJournalEntry =>
item != null &&
typeof item === 'object' &&
typeof (item as TaskStallJournalEntry).epochKey === 'string' &&
typeof (item as TaskStallJournalEntry).teamName === 'string' &&
typeof (item as TaskStallJournalEntry).taskId === 'string' &&
((item as TaskStallJournalEntry).branch === 'work' ||
(item as TaskStallJournalEntry).branch === 'review') &&
((item as TaskStallJournalEntry).signal === 'turn_ended_after_touch' ||
(item as TaskStallJournalEntry).signal === 'mid_turn_after_touch' ||
(item as TaskStallJournalEntry).signal === 'touch_then_other_turns') &&
isValidState((item as TaskStallJournalEntry).state) &&
typeof (item as TaskStallJournalEntry).consecutiveScans === 'number' &&
typeof (item as TaskStallJournalEntry).createdAt === 'string' &&
typeof (item as TaskStallJournalEntry).updatedAt === 'string'
)
.map((entry) => ({
...entry,
...(entry.alertedAt ? { alertedAt: entry.alertedAt } : {}),
}));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}
}

View file

@ -0,0 +1,246 @@
import { createLogger } from '@shared/utils/logger';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { ActiveTeamRegistry } from './ActiveTeamRegistry';
import {
getTeamTaskStallActivationGraceMs,
getTeamTaskStallScanIntervalMs,
getTeamTaskStallStartupGraceMs,
isTeamTaskStallAlertsEnabled,
isTeamTaskStallMonitorEnabled,
} from './featureGates';
import type { TeamTaskStallSnapshotSource } from './TeamTaskStallSnapshotSource';
import type { TeamTaskStallPolicy } from './TeamTaskStallPolicy';
import type { TeamTaskStallJournal } from './TeamTaskStallJournal';
import type { TeamTaskStallNotifier } from './TeamTaskStallNotifier';
import type { TaskStallAlert, TaskStallEvaluation } from './TeamTaskStallTypes';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('Service:TeamTaskStallMonitor');
interface TeamObservationState {
firstSeenAtMs: number;
lastActivationAtMs: number;
}
export class TeamTaskStallMonitor {
private scanTimer: ReturnType<typeof setTimeout> | null = null;
private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
private scanInFlight = false;
private started = false;
private readonly observationByTeam = new Map<string, TeamObservationState>();
constructor(
private readonly registry: ActiveTeamRegistry,
private readonly snapshotSource: TeamTaskStallSnapshotSource,
private readonly policy: TeamTaskStallPolicy,
private readonly journal: TeamTaskStallJournal,
private readonly notifier: TeamTaskStallNotifier
) {}
start(): void {
if (!isTeamTaskStallMonitorEnabled()) {
logger.debug('Task stall monitor disabled by feature gate');
return;
}
if (this.started) {
return;
}
this.started = true;
this.registry.start();
this.scheduleNextScan(2_000);
}
async stop(): Promise<void> {
this.started = false;
if (this.scanTimer) {
clearTimeout(this.scanTimer);
this.scanTimer = null;
}
if (this.nudgeTimer) {
clearTimeout(this.nudgeTimer);
this.nudgeTimer = null;
}
await this.registry.stop();
}
noteTeamChange(event: TeamChangeEvent): void {
this.registry.noteTeamChange(event);
if (!isTeamTaskStallMonitorEnabled()) {
return;
}
if (
event.type === 'member-spawn' ||
(event.type === 'lead-activity' && event.detail !== 'offline')
) {
const now = Date.now();
const existing = this.observationByTeam.get(event.teamName);
this.observationByTeam.set(event.teamName, {
firstSeenAtMs: existing?.firstSeenAtMs ?? now,
lastActivationAtMs: now,
});
this.scheduleNudgedScan();
return;
}
if (event.type === 'task-log-change' || event.type === 'log-source-change') {
this.scheduleNudgedScan();
}
}
private scheduleNextScan(delayMs: number): void {
if (!this.started) {
return;
}
if (this.scanTimer) {
clearTimeout(this.scanTimer);
}
this.scanTimer = setTimeout(() => {
this.scanTimer = null;
void this.runScan();
}, delayMs);
}
private scheduleNudgedScan(): void {
if (!this.started || this.nudgeTimer) {
return;
}
this.nudgeTimer = setTimeout(() => {
this.nudgeTimer = null;
void this.runScan();
}, 5_000);
}
private async runScan(): Promise<void> {
if (!this.started || this.scanInFlight) {
return;
}
this.scanInFlight = true;
try {
const activeTeams = await this.registry.listActiveTeams();
const activeSet = new Set(activeTeams);
for (const teamName of [...this.observationByTeam.keys()]) {
if (!activeSet.has(teamName)) {
this.observationByTeam.delete(teamName);
}
}
const now = new Date();
for (const teamName of activeTeams) {
const observation = this.getOrCreateObservation(teamName, now.getTime());
const startupAgeMs = now.getTime() - observation.firstSeenAtMs;
if (startupAgeMs < getTeamTaskStallStartupGraceMs()) {
continue;
}
const activationAgeMs = now.getTime() - observation.lastActivationAtMs;
if (activationAgeMs < getTeamTaskStallActivationGraceMs()) {
continue;
}
await this.scanTeam(teamName, now);
}
} catch (error) {
logger.warn(`Task stall monitor scan failed: ${String(error)}`);
} finally {
this.scanInFlight = false;
this.scheduleNextScan(getTeamTaskStallScanIntervalMs());
}
}
private getOrCreateObservation(teamName: string, nowMs: number): TeamObservationState {
const existing = this.observationByTeam.get(teamName);
if (existing) {
return existing;
}
const created = {
firstSeenAtMs: nowMs,
lastActivationAtMs: nowMs,
};
this.observationByTeam.set(teamName, created);
return created;
}
private async scanTeam(teamName: string, now: Date): Promise<void> {
const snapshot = await this.snapshotSource.getSnapshot(teamName);
if (!snapshot) {
return;
}
const evaluations: TaskStallEvaluation[] = [];
for (const task of snapshot.inProgressTasks) {
evaluations.push(this.policy.evaluateWork({ now, task, snapshot }));
}
for (const task of snapshot.reviewOpenTasks) {
evaluations.push(this.policy.evaluateReview({ now, task, snapshot }));
}
const activeTaskIds = [
...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)),
];
const readyEvaluations = await this.journal.reconcileScan({
teamName,
evaluations,
activeTaskIds,
now: now.toISOString(),
});
const alerts = readyEvaluations
.map((evaluation) => this.buildAlert(snapshot, evaluation))
.filter((alert): alert is TaskStallAlert => alert !== null);
if (alerts.length === 0) {
return;
}
if (!isTeamTaskStallAlertsEnabled()) {
logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`);
return;
}
await this.notifier.notifyLead(teamName, alerts);
await Promise.all(
alerts.map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString()))
);
}
private buildAlert(
snapshot: Awaited<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>,
evaluation: TaskStallEvaluation
): TaskStallAlert | null {
if (
!snapshot ||
evaluation.status !== 'alert' ||
!evaluation.taskId ||
!evaluation.branch ||
!evaluation.signal ||
!evaluation.epochKey
) {
return null;
}
const task = snapshot.allTasksById.get(evaluation.taskId);
if (!task) {
return null;
}
const displayId = getTaskDisplayId(task);
return {
teamName: snapshot.teamName,
taskId: task.id,
displayId,
subject: task.subject,
branch: evaluation.branch,
signal: evaluation.signal,
reason: evaluation.reason,
epochKey: evaluation.epochKey,
taskRef: {
taskId: task.id,
displayId,
teamName: snapshot.teamName,
},
};
}
}

View file

@ -0,0 +1,32 @@
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TaskStallAlert } from './TeamTaskStallTypes';
import type { TeamDataService } from '../TeamDataService';
function buildLeadAlertText(alerts: TaskStallAlert[]): string {
return alerts
.map(
(alert) =>
`- ${formatTaskDisplayLabel({ id: alert.taskId, displayId: alert.displayId })} [${alert.branch}] ${alert.subject} - ${alert.reason}`
)
.join('\n');
}
export class TeamTaskStallNotifier {
constructor(
private readonly teamDataService: Pick<TeamDataService, 'sendSystemNotificationToLead'>
) {}
async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise<void> {
if (alerts.length === 0) {
return;
}
await this.teamDataService.sendSystemNotificationToLead({
teamName,
summary: 'Potential stalled tasks detected',
text: buildLeadAlertText(alerts),
taskRefs: alerts.map((alert) => alert.taskRef),
});
}
}

View file

@ -0,0 +1,508 @@
import type {
ReviewTaskContext,
TaskStallBranch,
TaskStallEvaluation,
TaskStallSignal,
TeamTaskStallExactRow,
TeamTaskStallSnapshot,
WorkTaskContext,
} from './TeamTaskStallTypes';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { TeamTask, TaskWorkInterval, TaskHistoryEvent } from '@shared/types';
const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']);
const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']);
const ONE_MINUTE_MS = 60_000;
const WORK_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
turn_ended_after_touch: 8 * ONE_MINUTE_MS,
touch_then_other_turns: 10 * ONE_MINUTE_MS,
mid_turn_after_touch: 20 * ONE_MINUTE_MS,
};
const REVIEW_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
turn_ended_after_touch: 10 * ONE_MINUTE_MS,
touch_then_other_turns: 10 * ONE_MINUTE_MS,
mid_turn_after_touch: 25 * ONE_MINUTE_MS,
};
function skip(
taskId: string,
reason: string,
skipReason: TaskStallEvaluation['skipReason']
): TaskStallEvaluation {
return {
status: 'skip',
taskId,
reason,
skipReason,
};
}
function isAfterOrEqual(timestamp: string, lowerBound: string): boolean {
return Date.parse(timestamp) >= Date.parse(lowerBound);
}
function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null {
const intervals = task.workIntervals ?? [];
for (let i = intervals.length - 1; i >= 0; i -= 1) {
const interval = intervals[i];
if (!interval.completedAt) {
return interval;
}
}
return null;
}
function getOpenReviewWindowStart(task: TeamTask): string | null {
if (task.reviewState !== 'review' || !task.historyEvents?.length) {
return null;
}
for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) {
const event = task.historyEvents[i];
if (event.type === 'review_started') {
return event.timestamp;
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
(event.type === 'status_changed' && event.to === 'in_progress')
) {
return null;
}
}
return null;
}
function hasReviewStartedByReviewer(
historyEvents: TaskHistoryEvent[] | undefined,
reviewer: string,
windowStartedAt: string
): boolean {
if (!historyEvents?.length) {
return false;
}
return historyEvents.some(
(event) =>
event.type === 'review_started' &&
event.actor === reviewer &&
isAfterOrEqual(event.timestamp, windowStartedAt)
);
}
function isStrongReviewTouch(
record: BoardTaskActivityRecord,
reviewer: string,
hasExplicitStartedReview: boolean,
windowStartedAt: string
): boolean {
if (
record.actor.memberName !== reviewer ||
!record.action?.canonicalToolName ||
!REVIEW_TOUCH_TOOLS.has(record.action.canonicalToolName) ||
!isAfterOrEqual(record.timestamp, windowStartedAt)
) {
return false;
}
if (record.action.canonicalToolName === 'review_start') {
return true;
}
if (
record.actorContext.relation === 'same_task' &&
record.actorContext.activePhase === 'review'
) {
return true;
}
return hasExplicitStartedReview;
}
function findLastMeaningfulWorkTouch(
records: BoardTaskActivityRecord[],
owner: string,
intervalStartedAt: string
): BoardTaskActivityRecord | null {
return (
[...records]
.filter((record) => record.actor.memberName === owner)
.filter((record) => isAfterOrEqual(record.timestamp, intervalStartedAt))
.filter((record) => WORK_TOUCH_TOOLS.has(record.action?.canonicalToolName ?? ''))
.at(-1) ?? null
);
}
function findLastMeaningfulReviewTouch(
records: BoardTaskActivityRecord[],
reviewer: string,
windowStartedAt: string,
hasExplicitStartedReview: boolean
): BoardTaskActivityRecord | null {
return (
[...records]
.filter((record) =>
isStrongReviewTouch(record, reviewer, hasExplicitStartedReview, windowStartedAt)
)
.at(-1) ?? null
);
}
function anchorEvidenceRank(row: TeamTaskStallExactRow, toolUseId: string | undefined): number {
if (!toolUseId || row.parsedMessage.type !== 'assistant') {
return 0;
}
if (row.toolUseIds.includes(toolUseId)) {
return 2;
}
if (row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)) {
return 1;
}
return 0;
}
function deduplicateAssistantRowsByRequestId(
rows: TeamTaskStallExactRow[],
toolUseId: string | undefined
): TeamTaskStallExactRow[] {
const preferredIndexByRequestId = new Map<string, number>();
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
continue;
}
const existingIndex = preferredIndexByRequestId.get(row.requestId);
if (existingIndex === undefined) {
preferredIndexByRequestId.set(row.requestId, i);
continue;
}
const existingRank = anchorEvidenceRank(rows[existingIndex], toolUseId);
const nextRank = anchorEvidenceRank(row, toolUseId);
if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) {
preferredIndexByRequestId.set(row.requestId, i);
}
}
if (preferredIndexByRequestId.size === 0) {
return rows;
}
return rows.filter((row, index) => {
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
return true;
}
return preferredIndexByRequestId.get(row.requestId) === index;
});
}
function findAnchorRowIndex(
rows: TeamTaskStallExactRow[],
messageUuid: string,
toolUseId?: string
): number {
const candidates = rows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.messageUuid === messageUuid);
if (candidates.length === 0) {
return -1;
}
if (toolUseId) {
const explicitToolUse = candidates.filter(({ row }) => row.toolUseIds.includes(toolUseId));
if (explicitToolUse.length > 0) {
return explicitToolUse.at(-1)!.index;
}
const linkedRows = candidates.filter(
({ row }) => row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)
);
if (linkedRows.length > 0) {
return linkedRows.at(-1)!.index;
}
}
return candidates.at(-1)!.index;
}
function classifyPostTouchState(args: {
rows: TeamTaskStallExactRow[];
anchorMessageUuid: string;
anchorToolUseId?: string;
}): TaskStallSignal | 'ambiguous' {
const normalizedRows = deduplicateAssistantRowsByRequestId(args.rows, args.anchorToolUseId);
const anchorIndex = findAnchorRowIndex(
normalizedRows,
args.anchorMessageUuid,
args.anchorToolUseId
);
if (anchorIndex < 0) {
return 'ambiguous';
}
let sawTurnEnd = false;
let sawLaterRows = false;
for (let i = anchorIndex + 1; i < normalizedRows.length; i += 1) {
const row = normalizedRows[i];
if (row.systemSubtype === 'turn_duration') {
sawTurnEnd = true;
continue;
}
sawLaterRows = true;
if (sawTurnEnd) {
return 'touch_then_other_turns';
}
}
if (sawTurnEnd) {
return 'turn_ended_after_touch';
}
if (sawLaterRows) {
return 'mid_turn_after_touch';
}
return 'mid_turn_after_touch';
}
function buildEpochKey(
task: TeamTask,
branch: TaskStallBranch,
signal: TaskStallSignal,
touch: BoardTaskActivityRecord
): string {
return [
task.id,
branch,
signal,
touch.timestamp,
touch.source.filePath,
touch.source.messageUuid,
touch.source.toolUseId ?? 'ambient',
].join(':');
}
function buildAlertEvaluation(args: {
task: TeamTask;
branch: TaskStallBranch;
signal: TaskStallSignal;
touch: BoardTaskActivityRecord;
reason: string;
}): TaskStallEvaluation {
return {
status: 'alert',
taskId: args.task.id,
branch: args.branch,
signal: args.signal,
epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch),
reason: args.reason,
};
}
export class TeamTaskStallPolicy {
evaluateWork(args: {
now: Date;
task: TeamTask;
snapshot: TeamTaskStallSnapshot;
}): TaskStallEvaluation {
const { task, snapshot } = args;
if (!snapshot.activityReadsEnabled) {
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
}
if (!snapshot.exactReadsEnabled) {
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
}
if (task.status !== 'in_progress') {
return skip(task.id, 'Task is not in progress', 'task_not_in_progress');
}
if (!task.owner) {
return skip(task.id, 'Task has no owner', 'owner_missing');
}
if (task.owner === snapshot.leadName) {
return skip(task.id, 'Task owner is the lead', 'owner_is_lead');
}
if (task.reviewState === 'review') {
return skip(task.id, 'Task is currently under review', 'review_active');
}
if (task.blockedBy?.length) {
return skip(task.id, 'Task is blocked', 'task_blocked');
}
if (task.needsClarification) {
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
}
const openWorkInterval = getOpenWorkInterval(task);
if (!openWorkInterval?.startedAt) {
return skip(task.id, 'Task has no open work interval', 'no_open_work_interval');
}
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
return skip(
task.id,
'Task run is not instrumented enough for stall evaluation',
'non_instrumented_run'
);
}
const workContext: WorkTaskContext | null = (() => {
const touch = findLastMeaningfulWorkTouch(records, task.owner!, openWorkInterval.startedAt);
if (!touch) {
return null;
}
return {
owner: task.owner!,
intervalStartedAt: openWorkInterval.startedAt,
lastMeaningfulTouch: touch,
lastMeaningfulTouchAt: touch.timestamp,
};
})();
if (!workContext) {
return skip(
task.id,
'No positive work touch found in current work interval',
'no_positive_touch'
);
}
const exactRows = snapshot.exactRowsByFilePath.get(
workContext.lastMeaningfulTouch.source.filePath
);
if (!exactRows?.length) {
return skip(task.id, 'Post-touch exact rows are unavailable', 'ambiguous_state');
}
const signal = classifyPostTouchState({
rows: exactRows,
anchorMessageUuid: workContext.lastMeaningfulTouch.source.messageUuid,
anchorToolUseId: workContext.lastMeaningfulTouch.source.toolUseId,
});
if (signal === 'ambiguous') {
return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state');
}
const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt);
const thresholdMs = WORK_THRESHOLDS_MS[signal];
if (elapsedMs < thresholdMs) {
return skip(
task.id,
'Work touch is still below the configured stall threshold',
'below_threshold'
);
}
return buildAlertEvaluation({
task,
branch: 'work',
signal,
touch: workContext.lastMeaningfulTouch,
reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`,
});
}
evaluateReview(args: {
now: Date;
task: TeamTask;
snapshot: TeamTaskStallSnapshot;
}): TaskStallEvaluation {
const { task, snapshot } = args;
if (!snapshot.activityReadsEnabled) {
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
}
if (!snapshot.exactReadsEnabled) {
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
}
if (task.reviewState !== 'review') {
return skip(task.id, 'Task is not in an open review window', 'review_terminal');
}
if (task.needsClarification) {
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
}
const reviewWindowStartedAt = getOpenReviewWindowStart(task);
if (!reviewWindowStartedAt) {
return skip(task.id, 'Task has no open review window', 'no_open_review_window');
}
const resolvedReviewer = snapshot.resolvedReviewersByTaskId.get(task.id) ?? {
reviewer: null,
source: 'none',
};
if (!resolvedReviewer.reviewer) {
return skip(task.id, 'Reviewer could not be resolved safely', 'reviewer_unresolved');
}
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
return skip(
task.id,
'Review run is not instrumented enough for stall evaluation',
'non_instrumented_run'
);
}
const explicitReviewStarted = hasReviewStartedByReviewer(
task.historyEvents,
resolvedReviewer.reviewer,
reviewWindowStartedAt
);
const reviewContext: ReviewTaskContext | null = (() => {
const touch = findLastMeaningfulReviewTouch(
records,
resolvedReviewer.reviewer!,
reviewWindowStartedAt,
explicitReviewStarted
);
if (!touch) {
return null;
}
return {
resolvedReviewer,
reviewWindowStartedAt,
lastMeaningfulTouch: touch,
lastMeaningfulTouchAt: touch.timestamp,
};
})();
if (!reviewContext) {
return skip(task.id, 'No explicit started-review evidence was found', 'no_positive_touch');
}
const exactRows = snapshot.exactRowsByFilePath.get(
reviewContext.lastMeaningfulTouch.source.filePath
);
if (!exactRows?.length) {
return skip(task.id, 'Post-review exact rows are unavailable', 'ambiguous_state');
}
const signal = classifyPostTouchState({
rows: exactRows,
anchorMessageUuid: reviewContext.lastMeaningfulTouch.source.messageUuid,
anchorToolUseId: reviewContext.lastMeaningfulTouch.source.toolUseId,
});
if (signal === 'ambiguous') {
return skip(task.id, 'Post-review state is ambiguous', 'ambiguous_state');
}
const elapsedMs = args.now.getTime() - Date.parse(reviewContext.lastMeaningfulTouchAt);
const thresholdMs = REVIEW_THRESHOLDS_MS[signal];
if (elapsedMs < thresholdMs) {
return skip(
task.id,
'Review touch is still below the configured stall threshold',
'below_threshold'
);
}
return buildAlertEvaluation({
task,
branch: 'review',
signal,
touch: reviewContext.lastMeaningfulTouch,
reason: `Potential started-review stall after ${signal.replaceAll('_', ' ')}.`,
});
}
}

View file

@ -0,0 +1,119 @@
import { TeamTaskReader } from '../TeamTaskReader';
import { TeamKanbanManager } from '../TeamKanbanManager';
import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator';
import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader';
import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates';
import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates';
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader';
import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader';
import { buildResolvedReviewerIndex } from './reviewerResolution';
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes';
import type { TeamConfig, TeamTask } from '@shared/types';
function resolveLeadNameFromConfig(config: TeamConfig): string {
const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead'));
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
}
export class TeamTaskStallSnapshotSource {
constructor(
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(),
private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(),
private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader()
) {}
async getSnapshot(teamName: string): Promise<TeamTaskStallSnapshot | null> {
const transcriptContext = await this.transcriptSourceLocator.getContext(teamName);
if (!transcriptContext) {
return null;
}
const [activeTasks, deletedTasks, kanbanState] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.kanbanManager.getState(teamName),
]);
const allTasks = [...activeTasks, ...deletedTasks];
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
const inProgressTasks = activeTasks.filter(
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
);
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
let recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
if (
activityReadsEnabled &&
allTasks.length > 0 &&
transcriptContext.transcriptFiles.length > 0
) {
const messages = await this.transcriptReader.readFiles(transcriptContext.transcriptFiles);
recordsByTaskId = this.activityBatchIndexer.buildIndex({
teamName,
tasks: allTasks,
messages,
});
}
const relevantMonitorTasks = [...inProgressTasks, ...reviewOpenTasks];
const relevantExactFiles = this.collectRelevantExactFiles(
relevantMonitorTasks,
recordsByTaskId
);
const [freshnessByTaskId, exactRowsByFilePath] = await Promise.all([
this.freshnessReader.readSignals(
transcriptContext.projectDir,
relevantMonitorTasks.map((task) => task.id)
),
exactReadsEnabled
? this.exactRowReader.parseFiles(relevantExactFiles)
: Promise.resolve(new Map()),
]);
return {
teamName,
scannedAt: new Date().toISOString(),
projectDir: transcriptContext.projectDir,
projectId: transcriptContext.projectId,
leadName: resolveLeadNameFromConfig(transcriptContext.config),
transcriptFiles: transcriptContext.transcriptFiles,
activityReadsEnabled,
exactReadsEnabled,
activeTasks,
deletedTasks,
allTasksById,
inProgressTasks,
reviewOpenTasks,
resolvedReviewersByTaskId,
recordsByTaskId,
freshnessByTaskId,
exactRowsByFilePath,
};
}
private collectRelevantExactFiles(
inProgressTasks: TeamTask[],
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>
): string[] {
const filePaths = new Set<string>();
for (const task of inProgressTasks) {
const records = recordsByTaskId.get(task.id) ?? [];
for (const record of records) {
filePaths.add(record.source.filePath);
}
}
return [...filePaths].sort((left, right) => left.localeCompare(right));
}
}

View file

@ -0,0 +1,139 @@
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
import type { ParsedMessage } from '@main/types';
import type { TeamTask } from '@shared/types';
export type TaskStallBranch = 'work' | 'review';
export type TaskStallSignal =
| 'turn_ended_after_touch'
| 'mid_turn_after_touch'
| 'touch_then_other_turns';
export type TaskStallEvaluationStatus = 'skip' | 'suspected' | 'alert';
export type TaskStallSkipReason =
| 'task_not_in_progress'
| 'owner_missing'
| 'owner_is_lead'
| 'task_blocked'
| 'needs_clarification'
| 'review_active'
| 'review_terminal'
| 'reviewer_unresolved'
| 'non_instrumented_run'
| 'activity_reads_disabled'
| 'exact_reads_disabled'
| 'no_positive_touch'
| 'no_open_work_interval'
| 'no_open_review_window'
| 'ambiguous_state'
| 'below_threshold'
| 'first_scan_only';
export type ResolvedReviewerSource =
| 'kanban_state'
| 'history_review_approved_actor'
| 'history_review_started_actor'
| 'history_review_requested_reviewer'
| 'none';
export interface ResolvedReviewer {
reviewer: string | null;
source: ResolvedReviewerSource;
}
export interface TaskStallEvaluation {
status: TaskStallEvaluationStatus;
taskId?: string;
branch?: TaskStallBranch;
signal?: TaskStallSignal;
epochKey?: string;
reason: string;
skipReason?: TaskStallSkipReason;
}
export interface TaskLogFreshnessSignal {
taskId: string;
updatedAt: string;
filePath: string;
transcriptFileBasename?: string;
}
export interface TeamTaskStallExactRow {
filePath: string;
sourceOrder: number;
messageUuid: string;
timestamp: string;
parsedMessage: ParsedMessage;
requestId?: string;
sourceToolUseId?: string;
sourceToolAssistantUuid?: string;
systemSubtype?: 'turn_duration' | 'init';
toolUseIds: string[];
toolResultIds: string[];
}
export interface TeamTaskStallSnapshot {
teamName: string;
scannedAt: string;
projectDir: string;
projectId: string;
leadName: string;
transcriptFiles: string[];
activityReadsEnabled: boolean;
exactReadsEnabled: boolean;
activeTasks: TeamTask[];
deletedTasks: TeamTask[];
allTasksById: Map<string, TeamTask>;
inProgressTasks: TeamTask[];
reviewOpenTasks: TeamTask[];
resolvedReviewersByTaskId: Map<string, ResolvedReviewer>;
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
}
export interface WorkTaskContext {
owner: string;
intervalStartedAt: string;
lastMeaningfulTouch: BoardTaskActivityRecord;
lastMeaningfulTouchAt: string;
}
export interface ReviewTaskContext {
resolvedReviewer: ResolvedReviewer;
reviewWindowStartedAt: string;
lastMeaningfulTouch: BoardTaskActivityRecord;
lastMeaningfulTouchAt: string;
}
export interface TaskStallAlert {
teamName: string;
taskId: string;
displayId: string;
subject: string;
branch: TaskStallBranch;
signal: TaskStallSignal;
reason: string;
epochKey: string;
taskRef: {
taskId: string;
displayId: string;
teamName: string;
};
}
export type TaskStallJournalState = 'suspected' | 'alert_ready' | 'alerted';
export interface TaskStallJournalEntry {
epochKey: string;
teamName: string;
taskId: string;
branch: TaskStallBranch;
signal: TaskStallSignal;
state: TaskStallJournalState;
consecutiveScans: number;
createdAt: string;
updatedAt: string;
alertedAt?: string;
}

View file

@ -0,0 +1,42 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
function readInt(value: string | undefined, defaultValue: number): number {
if (value == null) {
return defaultValue;
}
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
}
export function isTeamTaskStallMonitorEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false);
}
export function isTeamTaskStallAlertsEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false);
}
export function getTeamTaskStallScanIntervalMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS, 60_000);
}
export function getTeamTaskStallStartupGraceMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS, 180_000);
}
export function getTeamTaskStallActivationGraceMs(): number {
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000);
}

View file

@ -0,0 +1,47 @@
import { TeamKanbanManager } from '../TeamKanbanManager';
import type { ResolvedReviewer } from './TeamTaskStallTypes';
import type { TeamTask } from '@shared/types';
export function resolveReviewerFromHistory(task: TeamTask): ResolvedReviewer {
if (!task.historyEvents?.length) {
return { reviewer: null, source: 'none' };
}
for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) {
const event = task.historyEvents[i];
if (event.type === 'review_approved' && event.actor) {
return { reviewer: event.actor, source: 'history_review_approved_actor' };
}
if (event.type === 'review_started' && event.actor) {
return { reviewer: event.actor, source: 'history_review_started_actor' };
}
if (event.type === 'review_requested' && event.reviewer) {
return { reviewer: event.reviewer, source: 'history_review_requested_reviewer' };
}
}
return { reviewer: null, source: 'none' };
}
export function buildResolvedReviewerIndex(
tasks: TeamTask[],
kanbanState: Awaited<ReturnType<TeamKanbanManager['getState']>>
): Map<string, ResolvedReviewer> {
const resolved = new Map<string, ResolvedReviewer>();
for (const task of tasks) {
const kanbanReviewer = kanbanState.tasks[task.id]?.reviewer;
if (typeof kanbanReviewer === 'string' && kanbanReviewer.trim().length > 0) {
resolved.set(task.id, {
reviewer: kanbanReviewer.trim(),
source: 'kanban_state',
});
continue;
}
resolved.set(task.id, resolveReviewerFromHistory(task));
}
return resolved;
}

View file

@ -312,6 +312,21 @@ function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityR
return left.id.localeCompare(right.id);
}
function resolveCandidateTaskIds(locator: BoardTaskLocator, lookup: TaskLookup): string[] {
const canonicalTask =
(locator.canonicalId && lookup.byId.get(locator.canonicalId)) ||
(locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) ||
(locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref)
? lookup.byId.get(locator.ref)
: undefined);
if (canonicalTask) {
return [canonicalTask.id];
}
const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? [];
return [...new Set(displayCandidates.map((task) => task.id))];
}
export class BoardTaskActivityRecordBuilder {
buildForTask(args: {
teamName: string;
@ -319,64 +334,98 @@ export class BoardTaskActivityRecordBuilder {
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): BoardTaskActivityRecord[] {
return (
this.buildForTasks({
teamName: args.teamName,
tasks: args.tasks,
messages: args.messages,
}).get(args.targetTask.id) ?? []
);
}
buildForTasks(args: {
teamName: string;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): Map<string, BoardTaskActivityRecord[]> {
const lookup = buildTaskLookup(args.tasks);
const records: BoardTaskActivityRecord[] = [];
const seenIds = new Set<string>();
const recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
const seenIdsByTaskId = new Map<string, Set<string>>();
for (const message of args.messages) {
const actionMap = buildActionMap(message.boardTaskToolActions);
for (const link of message.boardTaskLinks) {
const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup);
if (
resolvedTask.taskRef?.taskId !== args.targetTask.id &&
!locatorCouldMatchTask(link.task, args.targetTask, lookup)
) {
const candidateTaskIds = resolveCandidateTaskIds(link.task, lookup);
if (candidateTaskIds.length === 0) {
continue;
}
const action =
link.linkKind === 'execution' || !link.toolUseId
? undefined
: actionMap.get(link.toolUseId);
const peerTask = resolvePeerTask(
args.teamName,
link,
message.boardTaskLinks,
args.targetTask,
lookup
);
const record: BoardTaskActivityRecord = {
id: [
message.uuid,
link.toolUseId ?? 'ambient',
link.task.ref,
link.targetRole,
link.linkKind,
].join(':'),
timestamp: message.timestamp,
task: resolvedTask,
linkKind: link.linkKind,
targetRole: link.targetRole,
actor: resolveActivityActor(message),
actorContext: buildActorContext(args.teamName, link.actorContext, lookup),
...(action ? { action: buildAction({ action, link, peerTask }) } : {}),
source: {
messageUuid: message.uuid,
filePath: message.filePath,
...(link.toolUseId ? { toolUseId: link.toolUseId } : {}),
sourceOrder: message.sourceOrder,
},
};
if (seenIds.has(record.id)) {
continue;
for (const taskId of candidateTaskIds) {
const targetTask = lookup.byId.get(taskId);
if (!targetTask) {
continue;
}
if (
resolvedTask.taskRef?.taskId !== targetTask.id &&
!locatorCouldMatchTask(link.task, targetTask, lookup)
) {
continue;
}
const peerTask = resolvePeerTask(
args.teamName,
link,
message.boardTaskLinks,
targetTask,
lookup
);
const record: BoardTaskActivityRecord = {
id: [
message.uuid,
link.toolUseId ?? 'ambient',
link.task.ref,
link.targetRole,
link.linkKind,
].join(':'),
timestamp: message.timestamp,
task: resolvedTask,
linkKind: link.linkKind,
targetRole: link.targetRole,
actor: resolveActivityActor(message),
actorContext: buildActorContext(args.teamName, link.actorContext, lookup),
...(action ? { action: buildAction({ action, link, peerTask }) } : {}),
source: {
messageUuid: message.uuid,
filePath: message.filePath,
...(link.toolUseId ? { toolUseId: link.toolUseId } : {}),
sourceOrder: message.sourceOrder,
},
};
const seenIds = seenIdsByTaskId.get(taskId) ?? new Set<string>();
if (seenIds.has(record.id)) {
continue;
}
seenIds.add(record.id);
seenIdsByTaskId.set(taskId, seenIds);
const taskRecords = recordsByTaskId.get(taskId) ?? [];
taskRecords.push(record);
recordsByTaskId.set(taskId, taskRecords);
}
seenIds.add(record.id);
records.push(record);
}
}
return records.sort(compareRecords);
for (const [taskId, records] of recordsByTaskId) {
recordsByTaskId.set(taskId, records.sort(compareRecords));
}
return recordsByTaskId;
}
}

View file

@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
interface ListTeamsPayload {
teamsDir: string;
@ -593,6 +594,11 @@ async function listTeams(
dropCliProvisionerMembers(memberMap);
const members = Array.from(memberMap.values());
const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false });
const coloredMembers = members.map((member) => ({
...member,
color: memberColors.get(member.name) ?? member.color,
}));
const launchStateSummary =
(await readLaunchState(payload.teamsDir, teamName)) ??
(() => {
@ -623,7 +629,7 @@ async function listTeams(
memberCount: memberMap.size,
taskCount: 0,
lastActivity: null,
...(members.length > 0 ? { members } : {}),
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}),

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import {
getTeamColorSet,
getThemedBadge,
@ -5,7 +7,13 @@ import {
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import {
agentAvatarUrl,
buildMemberAvatarMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { MemberHoverCard } from './members/MemberHoverCard';
@ -40,6 +48,12 @@ export const MemberBadge = ({
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const teamMembers = useStore((s) =>
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
@ -53,7 +67,7 @@ export const MemberBadge = ({
const avatar = (
<img
src={agentAvatarUrl(name, avatarSize)}
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"

View file

@ -39,6 +39,7 @@ import {
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
@ -75,10 +76,11 @@ import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
@ -105,6 +107,10 @@ import { ScheduleSection } from './schedule/ScheduleSection';
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
import {
getTeamPendingRepliesState,
setTeamPendingRepliesState,
} from './sidebar/teamSidebarUiState';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ProcessesSection } from './ProcessesSection';
@ -126,6 +132,8 @@ import type {
ResolvedTeamMember,
TaskRef,
TeamAgentRuntimeEntry,
TeamCreateRequest,
TeamLaunchRequest,
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
@ -910,7 +918,9 @@ export const TeamDetailView = ({
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
} | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
getTeamPendingRepliesState(teamName)
);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false,
defaultSubject: '',
@ -923,7 +933,13 @@ export const TeamDetailView = ({
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [launchDialogState, setLaunchDialogState] = useState<{
open: boolean;
mode: TeamLaunchDialogMode;
}>({
open: false,
mode: 'launch',
});
const [editorOpen, setEditorOpen] = useState(false);
const [graphOpen, setGraphOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
@ -1155,6 +1171,7 @@ export const TeamDetailView = ({
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
{ teamName: string; displayName: string; projectPath: string }[]
>([]);
const launchDialogOpen = launchDialogState.open;
// Session loading and filtering state
const [sessions, setSessions] = useState<Session[]>([]);
@ -1200,6 +1217,8 @@ export const TeamDetailView = ({
clearProvisioningError,
isTeamProvisioning,
refreshTeamData,
refreshTeamMessagesHead,
refreshMemberActivityMeta,
syncTeamPendingReplyRefresh,
kanbanFilterQuery,
clearKanbanFilter,
@ -1251,6 +1270,8 @@ export const TeamDetailView = ({
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
refreshTeamData: s.refreshTeamData,
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
refreshMemberActivityMeta: s.refreshMemberActivityMeta,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
@ -1274,6 +1295,7 @@ export const TeamDetailView = ({
const tabId = useTabIdOptional();
const activeTabId = useStore((s) => s.activeTabId);
const isThisTabActive = tabId ? activeTabId === tabId : false;
const wasInteractiveRef = useRef(false);
useEffect(() => {
const now = Date.now();
@ -1337,6 +1359,14 @@ export const TeamDetailView = ({
}
}, [tabId, initTabUIState]);
useEffect(() => {
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
}, [teamName]);
useEffect(() => {
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
}, [pendingRepliesByMember, teamName]);
useEffect(() => {
const wasProvisioning = wasProvisioningRef.current;
wasProvisioningRef.current = isTeamProvisioning;
@ -1375,6 +1405,32 @@ export const TeamDetailView = ({
}
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
useEffect(() => {
const isInteractive = isThisTabActive && isPaneFocused;
const justBecameInteractive = isInteractive && !wasInteractiveRef.current;
wasInteractiveRef.current = isInteractive;
if (!justBecameInteractive || !teamName) {
return;
}
void (async () => {
try {
const headResult = await refreshTeamMessagesHead(teamName);
if (headResult.feedChanged) {
await refreshMemberActivityMeta(teamName);
}
} catch {
// Best-effort refresh on tab focus.
}
})();
}, [
isPaneFocused,
isThisTabActive,
refreshMemberActivityMeta,
refreshTeamMessagesHead,
teamName,
]);
// Fetch active teams when launch dialog opens (for conflict warning)
useEffect(() => {
if (!launchDialogOpen) return;
@ -1537,6 +1593,10 @@ export const TeamDetailView = ({
return nextMember;
});
}, [leadBranch, members, trackedBranches]);
const resolvedMemberColorMap = useMemo(
() => buildMemberColorMap(membersWithLiveBranches),
[membersWithLiveBranches]
);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => {
@ -1661,10 +1721,49 @@ export const TeamDetailView = ({
setSendDialogOpen(true);
}, []);
const handleRestartTeam = useCallback(() => {
setLaunchDialogOpen(true);
const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => {
setLaunchDialogState({ open: true, mode });
}, []);
const closeLaunchDialog = useCallback(() => {
setLaunchDialogState((prev) => ({ ...prev, open: false }));
}, []);
const handleRestartTeam = useCallback(() => {
openLaunchDialog('relaunch');
}, [openLaunchDialog]);
const handleLaunchDialogSubmit = useCallback(
async (request: TeamLaunchRequest): Promise<void> => {
await launchTeam(request);
},
[launchTeam]
);
const handleRelaunchDialogSubmit = useCallback(
async (
request: TeamLaunchRequest,
nextMembers: TeamCreateRequest['members']
): Promise<void> => {
await executeTeamRelaunch({
teamName,
isTeamAlive: data?.isAlive === true,
request,
members: nextMembers,
stopTeam: (nextTeamName) => api.teams.stop(nextTeamName),
replaceMembers: (nextTeamName, nextRequest) =>
api.teams.replaceMembers(nextTeamName, nextRequest),
launchTeam,
});
},
[data?.isAlive, launchTeam, teamName]
);
const handleChangeLeadRuntime = useCallback(() => {
setEditDialogOpen(false);
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
setSelectedMember(member);
setSelectedMemberView(null);
@ -1912,6 +2011,7 @@ export const TeamDetailView = ({
onReplyToMessage: handleReplyToMessage,
onRestartTeam: handleRestartTeam,
onTaskIdClick: handleTaskIdClick,
inlineScrollContainerRef: contentRef,
}),
[
activeMembers,
@ -2010,7 +2110,7 @@ export const TeamDetailView = ({
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => setLaunchDialogOpen(true)}
onClick={() => openLaunchDialog('launch')}
>
Launch
</button>
@ -2027,17 +2127,16 @@ export const TeamDetailView = ({
</div>
</div>
<LaunchTeamDialog
mode="launch"
mode={launchDialogState.mode}
open={launchDialogOpen}
teamName={teamName}
members={[]}
defaultProjectPath={draftTeamSummary?.projectPath}
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => {
await launchTeam(request);
}}
onClose={closeLaunchDialog}
onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
</>
);
@ -2168,12 +2267,17 @@ export const TeamDetailView = ({
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={isTeamProvisioning}
onClick={() => setEditDialogOpen(true)}
>
<Pencil size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit team</TooltipContent>
<TooltipContent side="bottom">
{isTeamProvisioning
? 'Edit team is unavailable while provisioning is still in progress'
: 'Edit team'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -2294,7 +2398,7 @@ export const TeamDetailView = ({
{!data.isAlive && !isTeamProvisioning ? (
<TeamOfflineStatusBanner
teamName={teamName}
onLaunch={() => setLaunchDialogOpen(true)}
onLaunch={() => openLaunchDialog('launch')}
/>
) : null}
@ -2708,9 +2812,13 @@ export const TeamDetailView = ({
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
resolvedMemberColorMap={resolvedMemberColorMap}
isTeamAlive={data.isAlive && !isTeamProvisioning}
isTeamProvisioning={isTeamProvisioning}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onChangeLeadRuntime={handleChangeLeadRuntime}
onSaved={() => void selectTeam(teamName)}
/>
@ -2801,7 +2909,7 @@ export const TeamDetailView = ({
</Dialog>
<LaunchTeamDialog
mode="launch"
mode={launchDialogState.mode}
open={launchDialogOpen}
teamName={teamName}
members={membersWithLiveBranches}
@ -2809,10 +2917,9 @@ export const TeamDetailView = ({
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
activeTeams={activeTeamsForLaunch}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => {
await launchTeam(request);
}}
onClose={closeLaunchDialog}
onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
<SendMessageDialog

View file

@ -6,6 +6,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -42,6 +43,7 @@ export const ActiveTasksBlock = ({
const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members);
const avatarMap = buildMemberAvatarMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const entries: ActivityEntry[] = [];
@ -115,7 +117,7 @@ export const ActiveTasksBlock = ({
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -1,4 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
areInboxMessagesEquivalentForRender,
@ -6,6 +14,7 @@ import {
areStringMapsEqual,
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Layers } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
@ -21,9 +30,60 @@ import {
} from './LeadThoughtsGroup';
import { useNewItemKeys } from './useNewItemKeys';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
/**
* A single visual row in the timeline. The render phase maps 1:1 from this
* list into JSX, which is the shape a windowing library (e.g.
* `@tanstack/react-virtual`) expects. Grouping happens earlier, in
* `groupTimelineItems`; this layer flattens groups/separators/dividers into
* atomic rows so each one can be measured and rendered independently.
*
* The `itemIndex` fields point back into `timelineItems` so per-item state
* (collapse mode, zebra shading, "is new" flag, session anchor) can still be
* resolved without threading it through every row entry.
*/
type TimelineRow =
| { kind: 'session-separator'; key: string }
| {
kind: 'lead-thought-group';
key: string;
itemIndex: number;
group: LeadThoughtGroup;
isPinned: boolean;
}
| { kind: 'compaction-divider'; key: string; message: InboxMessage }
| { kind: 'message-row'; key: string; itemIndex: number; message: InboxMessage };
/**
* Viewport contract describes the scroll container that hosts the timeline
* and how ActivityTimeline should report visibility against it. When omitted,
* ActivityTimeline falls back to the document viewport (current behavior).
*
* This contract is grouped intentionally so consumers pass a single coherent
* object rather than threading several refs and flags. Virtualizer wiring
* lands in a follow-up; for now only `observerRoot` has an observable effect.
*/
export interface TimelineViewport {
/** The element that actually scrolls. */
scrollElementRef: RefObject<HTMLElement | null>;
/**
* Root element for IntersectionObserver-based visibility tracking.
* Typically the same node as `scrollElementRef`, but left separate so
* future code can observe a more specific inner container when needed.
*/
observerRoot?: RefObject<HTMLElement | null>;
/**
* Distance from the scroll container's scroll origin to the timeline root,
* measured from the DOM. Zero in this release; used by the virtualizer in a
* follow-up change.
*/
scrollMargin?: number;
/** Enable virtualization (wired in a follow-up; ignored for now). */
virtualizationEnabled?: boolean;
}
interface ActivityTimelineProps {
messages: InboxMessage[];
teamName: string;
@ -66,6 +126,14 @@ interface ActivityTimelineProps {
onExpandItem?: (key: string) => void;
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
onExpandContent?: () => void;
/**
* Optional viewport contract. When provided, IntersectionObserver uses the
* passed `observerRoot` instead of the document viewport, which is required
* for correctness inside scrollable layouts (sidebar, bottom-sheet) where
* the row may be clipped by its scroll parent while still intersecting the
* page viewport.
*/
viewport?: TimelineViewport;
}
const VIEWPORT_THRESHOLD = 0.15;
@ -74,6 +142,59 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const DEFAULT_COLLAPSE_MODE = 'default' as const;
const VIRTUALIZER_OVERSCAN = 8;
const VIRTUALIZATION_ROW_GAP_PX = 4;
/**
* Row count above which virtualization is worth its complexity cost. Below
* this, the direct render path is both simpler and faster (no wrapper div,
* no position: absolute, no measurement churn). Chosen so conversations under
* roughly one session of activity stay on the direct path and the virtualized
* path only activates when scrolling behavior actually starts to matter.
*/
const VIRTUALIZATION_ROW_THRESHOLD = 60;
/**
* Per-kind height estimates for `estimateSize`. These are rough initial guesses
* only; the virtualizer re-measures rows as they mount via `measureElement`
* (wired in a follow-up PR), so small inaccuracies here are self-correcting.
* Sizes come from visually averaged steady-state heights in production layouts.
*/
const ROW_SIZE_ESTIMATES: Record<TimelineRow['kind'], number> = {
'session-separator': 135,
'compaction-divider': 50,
'lead-thought-group': 220,
'message-row': 140,
};
function collectScrollMarginObserverTargets(
rootElement: HTMLElement,
scrollElement: HTMLElement
): HTMLElement[] {
const targets = new Set<HTMLElement>([rootElement, scrollElement]);
let current: HTMLElement | null = rootElement;
while (current && current !== scrollElement) {
const parentElement: HTMLElement | null = current.parentElement;
if (!parentElement) {
break;
}
targets.add(parentElement);
let previousSibling: Element | null = current.previousElementSibling;
while (previousSibling) {
if (previousSibling instanceof HTMLElement) {
targets.add(previousSibling);
}
previousSibling = previousSibling.previousElementSibling;
}
current = parentElement;
}
return [...targets];
}
function getItemSessionAnchorId(item: TimelineItem): string | undefined {
if (item.type === 'lead-thoughts') {
@ -141,6 +262,7 @@ const MessageRowWithObserver = ({
onExpand,
expandItemKey,
onExpandContent,
observerRoot,
}: {
message: InboxMessage;
teamName: string;
@ -170,6 +292,7 @@ const MessageRowWithObserver = ({
onExpand?: (key: string) => void;
expandItemKey?: string;
onExpandContent?: () => void;
observerRoot?: RefObject<HTMLElement | null>;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -185,6 +308,10 @@ const MessageRowWithObserver = ({
if (!onVisible) return;
const el = ref.current;
if (!el) return;
// Resolve the observer root at effect-time. Falls back to the document
// viewport (null) when no root is provided — preserves pre-contract
// behavior for layouts without a known scroll owner.
const root = observerRoot?.current ?? null;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
@ -195,11 +322,11 @@ const MessageRowWithObserver = ({
reportedRef.current = true;
cb(msg);
},
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [onVisible]);
}, [onVisible, observerRoot]);
return (
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
@ -265,6 +392,7 @@ const MemoizedMessageRowWithObserver = React.memo(
prev.onExpand === next.onExpand &&
prev.expandItemKey === next.expandItemKey &&
prev.onExpandContent === next.onExpandContent &&
prev.observerRoot === next.observerRoot &&
areInboxMessagesEquivalentForRender(prev.message, next.message)
);
@ -291,7 +419,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onTeamClick,
onExpandItem,
onExpandContent,
viewport,
}: ActivityTimelineProps): React.JSX.Element {
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
const rootRef = useRef<HTMLDivElement>(null);
const [compactHeader, setCompactHeader] = useState(false);
@ -444,6 +574,129 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
const startIndex = pinnedThoughtGroup ? 1 : 0;
// Flatten timelineItems into atomic render rows. Each row maps to exactly
// one visual element — no Fragment bundles session separators with their
// owning item, because a windowing layer (landing in a follow-up PR) needs
// each row to be measurable and addressable independently.
const renderRows = useMemo<readonly TimelineRow[]>(() => {
const rows: TimelineRow[] = [];
if (pinnedThoughtGroup) {
rows.push({
kind: 'lead-thought-group',
key: getThoughtGroupKey(pinnedThoughtGroup.group),
itemIndex: 0,
group: pinnedThoughtGroup.group,
isPinned: true,
});
}
for (let i = startIndex; i < timelineItems.length; i += 1) {
const item = timelineItems[i];
if (i > 0) {
const currSessionId = getItemSessionAnchorId(item);
const prevSessionId = previousSessionAnchorByIndex[i];
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
// Include itemIndex in the key so a repeated transition (e.g. lead
// sessions A→B→A→B) does not collide on key `A->B` twice — React
// treats duplicate keys as the same element and reuses state
// across unrelated separators.
rows.push({
kind: 'session-separator',
key: `session-separator-${i}-${prevSessionId}->${currSessionId}`,
});
}
}
if (item.type === 'lead-thoughts') {
rows.push({
kind: 'lead-thought-group',
key: getThoughtGroupKey(item.group),
itemIndex: i,
group: item.group,
isPinned: false,
});
continue;
}
const message = item.message;
if (isCompactionMessage(message)) {
rows.push({
kind: 'compaction-divider',
key: `compaction-${toMessageKey(message)}`,
message,
});
continue;
}
rows.push({
kind: 'message-row',
key: toMessageKey(message),
itemIndex: i,
message,
});
}
return rows;
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
// Virtualizer gate — activates only when the parent opts in via
// `viewport.virtualizationEnabled`, the scroll element ref is present, and
// the row count is large enough for virtualization to pay for itself. Below
// the threshold the direct render path is both simpler and faster, so we
// keep it for short lists.
const shouldVirtualize =
viewport?.virtualizationEnabled === true &&
viewport.scrollElementRef != null &&
renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD;
// DOM-measured distance from the scroll container's scroll origin to the
// timeline root. We avoid re-measuring on every scroll: the offset only
// changes when layout above the timeline changes, so observe the timeline,
// its ancestor chain, and all previous siblings that can push it down.
const [measuredScrollMargin, setMeasuredScrollMargin] = useState(0);
useLayoutEffect(() => {
if (!shouldVirtualize) return;
const scrollEl = viewport?.scrollElementRef?.current ?? null;
const rootEl = rootRef.current;
if (!scrollEl || !rootEl) return;
let pending = false;
let rafId: number | null = null;
const measure = (): void => {
if (pending) return;
pending = true;
rafId = requestAnimationFrame(() => {
rafId = null;
pending = false;
const scrollRect = scrollEl.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
// Distance from top of scroll content to top of timeline root. Adding
// `scrollTop` compensates for the fact that both rects are relative
// to the viewport at measurement time, not the scrollable content.
const next = Math.max(0, rootRect.top - scrollRect.top + scrollEl.scrollTop);
setMeasuredScrollMargin((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
});
};
measure();
const resizeObserver = new ResizeObserver(measure);
const observedTargets = collectScrollMarginObserverTargets(rootEl, scrollEl);
observedTargets.forEach((target) => resizeObserver.observe(target));
window.addEventListener('resize', measure);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
window.removeEventListener('resize', measure);
};
}, [shouldVirtualize, viewport?.scrollElementRef]);
const rowVirtualizer = useVirtualizer({
count: shouldVirtualize ? renderRows.length : 0,
getScrollElement: () => viewport?.scrollElementRef?.current ?? null,
estimateSize: (index) => ROW_SIZE_ESTIMATES[renderRows[index]?.kind ?? 'message-row'],
getItemKey: (index) => renderRows[index]?.key ?? `row-${index}`,
overscan: VIRTUALIZER_OVERSCAN,
gap: VIRTUALIZATION_ROW_GAP_PX,
scrollMargin: measuredScrollMargin,
});
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
const newestMessageIndex = useMemo(() => {
return findNewestMessageIndex(timelineItems);
@ -485,6 +738,124 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
);
// Render a single atomic row. Logic per kind mirrors the previous inline
// render path; separators and dividers are their own rows rather than
// being bundled into Fragments, which is the contract the virtualizer will
// consume in a follow-up PR.
//
// `suppressEntryAnimation` is set when the caller is the virtualized path:
// the virtualizer mounts and unmounts rows as they enter and leave the
// viewport, so relying on mount as a signal of "this item is new" would
// replay the entry animation every time the user scrolls back to an old
// row. In the direct render path the flag stays false and animation still
// runs on real data-set additions.
const renderTimelineRow = (
row: TimelineRow,
options?: { suppressEntryAnimation?: boolean }
): React.JSX.Element | null => {
const suppressEntry = options?.suppressEntryAnimation === true;
switch (row.kind) {
case 'session-separator':
return (
<div
key={row.key}
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
case 'compaction-divider':
return <CompactionDivider key={row.key} message={row.message} />;
case 'lead-thought-group': {
const { group, itemIndex, isPinned, key } = row;
const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from);
const collapseProps = getItemCollapseProps(key, itemIndex);
const pinnedCanBeLive = isPinned
? currentLeadSessionId
? firstThought.leadSessionId === currentLeadSessionId
: true
: false;
return (
<LeadThoughtsGroupRow
key={key}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={!suppressEntry && newItemKeys.has(key)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(itemIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
/>
);
}
case 'message-row': {
const { message, itemIndex, key } = row;
const renderProps = resolveMessageRenderProps(message, ctx);
const collapseProps = getItemCollapseProps(key, itemIndex);
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
return (
<MemoizedMessageRowWithObserver
key={key}
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={!suppressEntry && newItemKeys.has(key)}
zebraShade={zebraShadeSet.has(itemIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
observerRoot={observerRoot}
onExpandContent={onExpandContent}
/>
);
}
}
};
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
@ -496,165 +867,49 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
return (
<div ref={rootRef} className="space-y-1">
{/* Pinned (newest) thought group — always at top */}
{pinnedThoughtGroup &&
(() => {
const { group } = pinnedThoughtGroup;
const firstThought = group.thoughts[0];
const pinnedCanBeLive = currentLeadSessionId
? firstThought.leadSessionId === currentLeadSessionId
: true;
const info = memberInfo.get(firstThought.from);
const itemKey = getThoughtGroupKey(group);
const stableKey = itemKey;
const collapseProps = getItemCollapseProps(stableKey, 0);
return (
<LeadThoughtsGroupRow
key={itemKey}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
);
})()}
{/* Remaining items */}
{timelineItems.slice(startIndex).map((item, index) => {
const realIndex = index + startIndex;
// Session boundary separator (messages sorted desc — new on top)
let sessionSeparator: React.JSX.Element | null = null;
if (realIndex > 0) {
const currSessionId = getItemSessionAnchorId(item);
const prevSessionId = previousSessionAnchorByIndex[realIndex];
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
{shouldVirtualize ? (
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = renderRows[virtualRow.index];
if (!row) return null;
return (
<div
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
key={virtualRow.key}
// `measureElement` swaps each row's estimated height for its
// real rendered height as it mounts, so the virtualizer can
// correct totalSize and downstream row positions. The wrapper
// div carries no padding/margin, so its bounding box matches
// the inner row's bounding box — this is why a merged ref
// callback between the observer and `measureElement` isn't
// needed here.
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// `translateY` is offset by scrollMargin so the virtualizer
// positions rows relative to the timeline's own origin,
// not the scroll container's top — otherwise rows would
// overlap the composer / status block at the top.
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
{renderTimelineRow(row, { suppressEntryAnimation: true })}
</div>
);
}
}
if (item.type === 'lead-thoughts') {
const { group } = item;
const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from);
const itemKey = getThoughtGroupKey(group);
const stableKey = itemKey;
const collapseProps = getItemCollapseProps(stableKey, realIndex);
return (
<React.Fragment key={itemKey}>
{sessionSeparator}
<LeadThoughtsGroupRow
group={group}
memberColor={info?.color}
canBeLive={false}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(realIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
</React.Fragment>
);
}
const { message } = item;
// Compaction boundary — render as a divider instead of a regular message card
if (isCompactionMessage(message)) {
const messageKey = toMessageKey(message);
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<CompactionDivider message={message} />
</React.Fragment>
);
}
const renderProps = resolveMessageRenderProps(message, ctx);
const messageKey = toMessageKey(message);
const stableKey = messageKey;
const collapseProps = getItemCollapseProps(stableKey, realIndex);
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<MemoizedMessageRowWithObserver
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(messageKey)}
zebraShade={zebraShadeSet.has(realIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? messageKey : undefined}
onExpandContent={onExpandContent}
/>
</React.Fragment>
);
})}
})}
</div>
) : (
renderRows.map((row) => renderTimelineRow(row))
)}
{hiddenCount > 0 && (
<div className="relative flex justify-center pb-3 pt-1">
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}

View file

@ -1,6 +1,7 @@
import {
type JSX,
memo,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string;
isNew?: boolean;
onVisible?: (message: InboxMessage) => void;
/**
* Root element for IntersectionObserver-based visibility tracking. When
* omitted, the observer falls back to the document viewport correct for
* top-level renders, incorrect when the row is inside a scroll container
* (sidebar, bottom-sheet) that can clip the row while the document
* viewport still contains it.
*/
observerRoot?: RefObject<HTMLElement | null>;
/** When false, the live indicator is always off (for historical thought groups). */
canBeLive?: boolean;
/** Whether the owning team is currently alive. */
@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({
memberColor,
isNew,
onVisible,
observerRoot,
canBeLive,
isTeamAlive,
leadActivity,
@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({
if (!onVisible) return;
const el = ref.current;
if (!el) return;
// Resolve observer root at effect-time. Falls back to the document
// viewport when no root is provided — preserves pre-contract behavior.
const root = observerRoot?.current ?? null;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({
}
reportedCountRef.current = thoughts.length;
},
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [onVisible, thoughts]);
}, [onVisible, observerRoot, thoughts]);
const clearPendingScrollSync = useCallback(() => {
if (scrollSyncFrameRef.current !== null) {
@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo(
prev.compactHeader === next.compactHeader &&
prev.onExpand === next.onExpand &&
prev.expandItemKey === next.expandItemKey &&
prev.observerRoot === next.observerRoot &&
areThoughtGroupsEquivalent(prev.group, next.group)
);

View file

@ -9,7 +9,7 @@ import {
} from '@renderer/components/ui/dialog';
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers';
import { MemberBadge } from '../MemberBadge';
@ -28,6 +28,7 @@ function formatTime(timestamp: string): string {
interface DialogThoughtsContentProps {
group: LeadThoughtGroup;
members?: ResolvedTeamMember[];
memberColor?: string;
onTaskIdClick?: (taskId: string) => void;
onReply?: (message: InboxMessage) => void;
@ -39,6 +40,7 @@ interface DialogThoughtsContentProps {
const DialogThoughtsContent = ({
group,
members,
memberColor,
onTaskIdClick,
onReply,
@ -51,6 +53,7 @@ const DialogThoughtsContent = ({
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
const colors = getTeamColorSet(memberColor ?? '');
const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]);
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
return (
@ -58,7 +61,7 @@ const DialogThoughtsContent = ({
{/* Header */}
<div className="flex items-center gap-2 pb-3">
<img
src={agentAvatarUrl(newest.from, 32)}
src={avatarMap.get(newest.from) ?? agentAvatarUrl(newest.from, 32)}
alt=""
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
@ -193,6 +196,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
) : displayItem?.type === 'lead-thoughts' ? (
<DialogThoughtsContent
group={displayItem.group}
members={members}
memberColor={thoughtMemberColor}
onTaskIdClick={onTaskIdClick}
onReply={onReplyToMessage}

View file

@ -5,6 +5,7 @@ import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
getMemberRuntimeAdvisoryLabel,
@ -41,6 +42,7 @@ export const PendingRepliesBlock = ({
const { isLight } = useTheme();
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members);
const avatarMap = buildMemberAvatarMap(members);
const memberPending = Object.entries(pendingRepliesByMember)
.map(([name, sentAtMs]) => ({
kind: 'member' as const,
@ -111,7 +113,7 @@ export const PendingRepliesBlock = ({
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -48,6 +48,7 @@ import {
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@ -941,7 +942,14 @@ export const CreateTeamDialog = ({
const mentionSuggestions = useMemo(
() =>
soloTeam
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
? [
{
id: 'team-lead',
name: 'team-lead',
subtitle: 'Team Lead',
color: resolveTeamLeadColorName(),
},
]
: buildMemberDraftSuggestions(members, memberColorMap),
[memberColorMap, members, soloTeam]
);
@ -1219,7 +1227,7 @@ export const CreateTeamDialog = ({
}
}}
>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
<DialogDescription className="text-xs">

View file

@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
buildMembersFromDrafts,
createMemberDraftsFromInputs,
filterEditableMemberInputs,
createMemberDraft,
MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -21,8 +23,21 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import {
agentAvatarUrl,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { Loader2 } from 'lucide-react';
import {
buildEditTeamSourceSnapshot,
getMemberRuntimeContractKey,
getLiveRosterIdentityChanges,
getMembersRequiringRuntimeRestart,
} from './editTeamRuntimeChanges';
import type { ResolvedTeamMember } from '@shared/types';
const TEAM_COLOR_NAMES = [
@ -43,16 +58,73 @@ interface EditTeamDialogProps {
currentDescription: string;
currentColor: string;
currentMembers: ResolvedTeamMember[];
leadMember?: ResolvedTeamMember | null;
resolvedMemberColorMap?: ReadonlyMap<string, string>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
projectPath?: string | null;
onClose: () => void;
onSaved: () => void;
onChangeLeadRuntime: () => void;
onSaved: () => Promise<void> | void;
}
function membersToDrafts(members: ResolvedTeamMember[]) {
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
}
function useEditTeamErrorReset(
setError: (value: string | null) => void,
setSaveOutcomeError: (value: string | null) => void
): () => void {
return () => {
setError(null);
setSaveOutcomeError(null);
};
}
function getInvalidMemberNamesError(
members: readonly {
name: string;
removedAt?: number | string | null;
}[]
): string | null {
for (const member of members) {
if (member.removedAt) {
continue;
}
const name = member.name.trim();
if (!name) {
return 'Member name cannot be empty';
}
if (validateMemberNameInline(name) !== null) {
return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
const lower = name.toLowerCase();
if (lower === 'user' || lower === 'team-lead') {
return `Member name "${name}" is reserved`;
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
}
}
return null;
}
function applyRemovedMembersToSnapshot(
members: readonly ResolvedTeamMember[],
removedMemberNames: readonly string[]
): ResolvedTeamMember[] {
if (removedMemberNames.length === 0) {
return [...members];
}
const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase()));
const removedAt = Date.now();
return members.map((member) =>
removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member
);
}
export const EditTeamDialog = ({
open,
teamName,
@ -60,9 +132,13 @@ export const EditTeamDialog = ({
currentDescription,
currentColor,
currentMembers,
leadMember = null,
resolvedMemberColorMap,
isTeamAlive = false,
isTeamProvisioning = false,
projectPath,
onClose,
onChangeLeadRuntime,
onSaved,
}: EditTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
@ -72,39 +148,305 @@ export const EditTeamDialog = ({
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState<
Record<string, string>
>({});
const wasOpenRef = useRef(false);
const initializedTeamNameRef = useRef<string | null>(null);
const baselineSourceSnapshotRef = useRef<string | null>(null);
const pendingCommittedSourceSnapshotRef = useRef<string | null>(null);
useFileListCacheWarmer(projectPath ?? null);
const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError);
const effectiveResolvedMemberColorMap = useMemo(
() => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers),
[currentMembers, resolvedMemberColorMap]
);
const leadDraft = useMemo(() => {
if (!leadMember) return null;
return createMemberDraft({
id: `lead:${leadMember.name}`,
name: displayMemberName(leadMember.name),
originalName: leadMember.name,
roleSelection: '',
customRole: 'Team Lead',
workflow: leadMember.workflow,
providerId: leadMember.providerId,
model: leadMember.model ?? '',
effort: leadMember.effort,
});
}, [leadMember]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
if (open) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setError(null);
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
if (shouldInitialize) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setError(null);
setSaveOutcomeError(null);
setMembersPendingRestartRetry({});
initializedTeamNameRef.current = teamName;
baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
pendingCommittedSourceSnapshotRef.current = null;
} else if (pendingCommittedSourceSnapshotRef.current !== null) {
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) {
baselineSourceSnapshotRef.current = latestSourceSnapshot;
pendingCommittedSourceSnapshotRef.current = null;
}
}
} else if (wasOpen) {
initializedTeamNameRef.current = null;
baselineSourceSnapshotRef.current = null;
pendingCommittedSourceSnapshotRef.current = null;
}
}, [open, currentName, currentDescription, currentColor, currentMembers]);
wasOpenRef.current = open;
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
const hasDuplicateMembers = useMemo(() => {
const names = members
.filter((member) => !member.removedAt)
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(names).size !== names.length;
}, [members]);
const membersToRestart = useMemo(
() =>
isTeamAlive
? getMembersRequiringRuntimeRestart({
previousMembers: currentMembers,
nextMembers: builtMembers,
})
: [],
[builtMembers, currentMembers, isTeamAlive]
);
const builtMembersByName = useMemo(
() =>
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
[builtMembers]
);
const effectiveMembersToRestart = useMemo(() => {
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
([normalizedName, expectedRuntimeContractKey]) => {
const nextMember = builtMembersByName.get(normalizedName);
if (!nextMember) {
return [];
}
return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey
? [nextMember.name.trim()]
: [];
}
);
return Array.from(
new Set(
[...membersToRestart, ...retryMembers]
.map((memberName) => memberName.trim())
.filter(Boolean)
)
);
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
const liveIdentityChanges = useMemo(
() =>
isTeamAlive
? getLiveRosterIdentityChanges({
previousMembers: currentMembers,
nextDrafts: members,
})
: { renamed: [], removed: [] },
[currentMembers, isTeamAlive, members]
);
const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0;
const liveRemovedExistingMembers = useMemo(
() => (isTeamAlive ? liveIdentityChanges.removed : []),
[isTeamAlive, liveIdentityChanges.removed]
);
const hasNewLiveTeammates = useMemo(
() =>
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
[isTeamAlive, members]
);
const memberWarningById = useMemo(() => {
const restartNames = new Set(
effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase())
);
if (restartNames.size === 0) {
return undefined;
}
return Object.fromEntries(
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
: null,
])
);
}, [effectiveMembersToRestart, members]);
const handleSave = (): void => {
if (!name.trim()) {
setError('Team name cannot be empty');
return;
}
const builtMembers = buildMembersFromDrafts(members);
if (invalidMemberNamesError) {
setError(invalidMemberNamesError);
return;
}
if (hasDuplicateMembers) {
setError('Member names must be unique before saving');
return;
}
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
const allowedSourceSnapshots = new Set(
[baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter(
(value): value is string => value !== null
)
);
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
setError(
'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
);
return;
}
if (hasBlockedLiveIdentityChanges) {
setError(
`Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
);
return;
}
if (isTeamProvisioning) {
setError(
'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
);
return;
}
if (hasNewLiveTeammates) {
setError(
'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
);
return;
}
setSaving(true);
setError(null);
setSaveOutcomeError(null);
void (async () => {
let configSaved = false;
let membersSaved = false;
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
try {
await api.teams.updateConfig(teamName, {
name: name.trim(),
description: description.trim(),
color,
});
configSaved = true;
for (const removedMemberName of liveRemovedExistingMembers) {
await api.teams.removeMember(teamName, removedMemberName);
committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [
removedMemberName,
]);
}
await api.teams.replaceMembers(teamName, { members: builtMembers });
onSaved();
onClose();
membersSaved = true;
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: builtMembers.map((member) => ({
name: member.name,
role: member.role,
workflow: member.workflow,
providerId: member.providerId,
model: member.model,
effort: member.effort,
})) as ResolvedTeamMember[],
});
const restartFailures: string[] = [];
const failedRestartMembers: string[] = [];
for (const memberName of effectiveMembersToRestart) {
try {
await api.teams.restartMember(teamName, memberName);
} catch (restartError) {
const detail =
restartError instanceof Error ? restartError.message : String(restartError);
failedRestartMembers.push(memberName);
restartFailures.push(`${memberName} (${detail})`);
}
}
await Promise.resolve(onSaved());
if (restartFailures.length === 0) {
setMembersPendingRestartRetry({});
onClose();
return;
}
setMembersPendingRestartRetry(
Object.fromEntries(
failedRestartMembers.flatMap((memberName) => {
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
if (!nextMember) {
return [];
}
return [
[memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const,
];
})
)
);
setSaveOutcomeError(
`Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save');
const message = e instanceof Error ? e.message : 'Failed to save';
if (membersSaved) {
setSaveOutcomeError(
`Team changes were saved, but failed to refresh the latest view: ${message}`
);
} else if (configSaved) {
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: committedMembersForSnapshot,
});
let refreshErrorDetail: string | null = null;
try {
await Promise.resolve(onSaved());
} catch (refreshError) {
refreshErrorDetail =
refreshError instanceof Error ? refreshError.message : String(refreshError);
}
setSaveOutcomeError(
refreshErrorDetail
? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
: `Team settings were saved, but member changes failed: ${message}`
);
} else {
setError(message);
}
} finally {
setSaving(false);
}
@ -113,7 +455,7 @@ export const EditTeamDialog = ({
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Team</DialogTitle>
<DialogDescription>Change team name, description and color</DialogDescription>
@ -131,7 +473,10 @@ export const EditTeamDialog = ({
id="edit-team-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
clearTransientErrors();
setName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
}}
@ -149,7 +494,10 @@ export const EditTeamDialog = ({
<textarea
id="edit-team-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => {
clearTransientErrors();
setDescription(e.target.value);
}}
rows={3}
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team description (optional)"
@ -158,20 +506,90 @@ export const EditTeamDialog = ({
<div>
<MembersEditorSection
members={members}
onChange={setMembers}
onChange={(nextMembers) => {
clearTransientErrors();
setMembers(nextMembers);
}}
fieldError={invalidMemberNamesError ?? undefined}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor={!isTeamAlive}
draftKeyPrefix={`editTeam:${teamName}`}
projectPath={projectPath ?? null}
headerExtra={
leadDraft ? (
<div className="space-y-2">
<MemberDraftRow
member={leadDraft}
index={0}
avatarSrc={agentAvatarUrl('team-lead', 32)}
resolvedColor={effectiveResolvedMemberColorMap.get(
leadDraft.originalName ?? leadDraft.name
)}
nameError={null}
onNameChange={() => undefined}
onRoleChange={() => undefined}
onCustomRoleChange={() => undefined}
onRemove={() => undefined}
onProviderChange={() => undefined}
onModelChange={() => undefined}
onEffortChange={() => undefined}
projectPath={projectPath ?? null}
lockProviderModel
lockRole
lockedRoleLabel="Team Lead"
lockIdentity
hideActionButton
modelLockReason="Team lead runtime is managed from Relaunch Team."
lockedModelAction={{
label: 'Change lead runtime',
description:
'Open Relaunch Team to change the lead provider, model, or effort.',
onClick: onChangeLeadRuntime,
disabled: isTeamProvisioning,
}}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Team lead name and role stay read-only here. Open the runtime panel on the
lead row to change provider, model, or effort.
</p>
</div>
) : null
}
existingMembers={currentMembers}
lockProviderModel={isTeamAlive}
existingMemberColorMap={effectiveResolvedMemberColorMap}
lockProviderModel={false}
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
disableAddMember={isTeamAlive}
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
memberWarningById={memberWarningById}
/>
</div>
{isTeamAlive ? (
{isTeamProvisioning ? (
<p className="text-xs text-amber-300">
Provider and model changes are locked while the team is live. Reconnect the team to
change them safely.
Team provisioning is still in progress. Editing is temporarily locked until launch
finishes.
</p>
) : null}
{isTeamAlive && hasNewLiveTeammates ? (
<p className="text-xs text-red-300">
New teammates cannot be added from Edit Team while the team is live. Use the Add
member dialog instead.
</p>
) : null}
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
<p className="text-xs text-red-300">
Live save is blocked because existing teammates were renamed. Revert those identity
changes or stop the team first.
</p>
) : null}
{isTeamAlive && effectiveMembersToRestart.length > 0 ? (
<p className="text-xs text-amber-300">
Saving will restart{' '}
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
apply role, workflow, provider, model, or effort changes:{' '}
{effectiveMembersToRestart.join(', ')}.
</p>
) : null}
<div>
@ -196,7 +614,10 @@ export const EditTeamDialog = ({
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setColor(isSelected ? '' : colorName)}
onClick={() => {
clearTransientErrors();
setColor(isSelected ? '' : colorName);
}}
>
<span
className="size-3.5 rounded-full"
@ -207,14 +628,26 @@ export const EditTeamDialog = ({
})}
</div>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{(error || saveOutcomeError) && (
<p className="text-xs text-red-400">{error ?? saveOutcomeError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !name.trim()}>
<Button
size="sm"
onClick={handleSave}
disabled={
saving ||
isTeamProvisioning ||
!name.trim() ||
hasDuplicateMembers ||
Boolean(invalidMemberNamesError)
}
>
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
Save
</Button>

View file

@ -107,6 +107,7 @@ import type {
ResolvedTeamMember,
Schedule,
ScheduleLaunchConfig,
TeamCreateRequest,
TeamLaunchRequest,
TeamProviderId,
UpdateSchedulePatch,
@ -140,6 +141,8 @@ interface LaunchDialogBase {
onClose: () => void;
}
export type TeamLaunchDialogMode = 'launch' | 'relaunch';
interface LaunchDialogLaunchMode extends LaunchDialogBase {
mode: 'launch';
members: ResolvedTeamMember[];
@ -150,6 +153,16 @@ interface LaunchDialogLaunchMode extends LaunchDialogBase {
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
}
interface LaunchDialogRelaunchMode extends LaunchDialogBase {
mode: 'relaunch';
members: ResolvedTeamMember[];
defaultProjectPath?: string;
provisioningError: string | null;
clearProvisioningError?: (teamName?: string) => void;
activeTeams?: ActiveTeamRef[];
onRelaunch: (request: TeamLaunchRequest, members: TeamCreateRequest['members']) => Promise<void>;
}
interface LaunchDialogScheduleMode {
mode: 'schedule';
open: boolean;
@ -160,7 +173,10 @@ interface LaunchDialogScheduleMode {
schedule?: Schedule | null;
}
export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode;
export type LaunchTeamDialogProps =
| LaunchDialogLaunchMode
| LaunchDialogRelaunchMode
| LaunchDialogScheduleMode;
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
@ -234,7 +250,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const isLaunch = props.mode === 'launch';
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
const isRelaunch = props.mode === 'relaunch';
const isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null;
const isEditing = isSchedule && !!schedule;
@ -318,7 +335,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const previousLaunchParams = useStore((s) =>
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
);
const members = isLaunch ? props.members : storeMembers;
const members = isLaunchMode ? props.members : storeMembers;
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
null
@ -541,7 +558,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
const closeDialog = (): void => {
if (isLaunch) {
if (isLaunchMode) {
resetFormState();
}
onClose();
@ -599,7 +616,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [open, isSchedule, schedule?.id]);
useEffect(() => {
if (!open || !isLaunch) return;
if (!open || !isLaunchMode) return;
let cancelled = false;
void (async () => {
@ -669,10 +686,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
}, [open, isLaunchMode, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
const previousProviderId = useMemo<TeamProviderId | null>(() => {
if (!isLaunch) {
if (!isLaunchMode) {
return null;
}
const fromLaunchParams = previousLaunchParams?.providerId;
@ -684,14 +701,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return fromLaunchParams;
}
return savedLaunchProviderId;
}, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]);
}, [isLaunchMode, previousLaunchParams?.providerId, savedLaunchProviderId]);
const providerChangeForcesFreshLeadContext = useMemo(() => {
if (!isLaunch || !previousProviderId) {
if (!isLaunchMode || !previousProviderId) {
return false;
}
return previousProviderId !== selectedProviderId;
}, [isLaunch, previousProviderId, selectedProviderId]);
}, [isLaunchMode, previousProviderId, selectedProviderId]);
const effectiveLeadRuntimeModel = useMemo(
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
@ -744,7 +761,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]);
const runtimeChangeNotes = useMemo(() => {
if (!isLaunch) {
if (!isLaunchMode) {
return [] as { key: string; memberName: string; message: string }[];
}
@ -836,7 +853,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return notes;
}, [
isLaunch,
isLaunchMode,
previousLaunchParams?.effort,
previousLaunchParams?.model,
previousProviderId,
@ -895,14 +912,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (!open || !isLaunch) return;
if (!open || !isLaunchMode) return;
props.clearProvisioningError?.(effectiveTeamName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLaunch, effectiveTeamName]);
}, [open, isLaunchMode, effectiveTeamName]);
// Warm up CLI for the currently selected working directory (launch mode only).
useEffect(() => {
if (!open || !isLaunch) return;
if (!open || !isLaunchMode) return;
if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed');
@ -1052,7 +1069,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
}, [
open,
isLaunch,
isLaunchMode,
effectiveCwd,
selectedProviderId,
selectedMemberProviders,
@ -1111,7 +1128,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [open, repositoryGroups]);
// Pre-select defaultProjectPath (launch mode) or first project
const defaultProjectPath = isLaunch ? props.defaultProjectPath : undefined;
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
useEffect(() => {
if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return;
@ -1132,17 +1149,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Launch-only: conflict detection
// ---------------------------------------------------------------------------
const activeTeams = isLaunch ? props.activeTeams : undefined;
const activeTeams = isLaunchMode ? props.activeTeams : undefined;
const conflictingTeam = useMemo(() => {
if (!isLaunch || !activeTeams?.length || !effectiveCwd) return null;
if (!isLaunchMode || !activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd);
return (
activeTeams.find(
(t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm
) ?? null
);
}, [isLaunch, activeTeams, effectiveCwd, effectiveTeamName]);
}, [isLaunchMode, activeTeams, effectiveCwd, effectiveTeamName]);
useEffect(() => {
setConflictDismissed(false);
@ -1168,7 +1185,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// ---------------------------------------------------------------------------
const internalArgs = useMemo(() => {
if (!isLaunch) return [];
if (!isLaunchMode) return [];
const args: string[] = [];
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
args.push('--verbose', '--setting-sources', 'user,project,local');
@ -1180,7 +1197,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (!clearContext) args.push('--resume', '<previous>');
return args;
}, [
isLaunch,
isLaunchMode,
skipPermissions,
selectedModel,
limitContext,
@ -1190,7 +1207,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
]);
const launchOptionalSummary = useMemo(() => {
if (!isLaunch) return [];
if (!isLaunchMode) return [];
const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt');
@ -1204,7 +1221,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
}, [
isLaunch,
isLaunchMode,
promptDraft.value,
selectedModel,
selectedProviderId,
@ -1241,7 +1258,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return leadError;
}
if (!isLaunch) {
if (!isLaunchMode) {
return null;
}
@ -1267,7 +1284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return null;
}, [
effectiveMemberDrafts,
isLaunch,
isLaunchMode,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
@ -1282,7 +1299,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]);
const memberModelIssueById = useMemo(() => {
const next: Record<string, string> = {};
if (!isLaunch) {
if (!isLaunchMode) {
return next;
}
for (const member of effectiveMemberDrafts) {
@ -1303,7 +1320,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return next;
}, [
effectiveMemberDrafts,
isLaunch,
isLaunchMode,
leadModelIssueText,
prepareChecks,
selectedProviderId,
@ -1311,32 +1328,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
]);
const hasInvalidLaunchMemberNames = useMemo(
() =>
isLaunch &&
isLaunchMode &&
membersDrafts.some(
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
),
[isLaunch, membersDrafts]
[isLaunchMode, membersDrafts]
);
const hasDuplicateLaunchMemberNames = useMemo(() => {
if (!isLaunch) return false;
if (!isLaunchMode) return false;
const activeNames = membersDrafts
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(activeNames).size !== activeNames.length;
}, [isLaunch, membersDrafts]);
}, [isLaunchMode, membersDrafts]);
// ---------------------------------------------------------------------------
// Error
// ---------------------------------------------------------------------------
const provisioningError = isLaunch ? props.provisioningError : null;
const provisioningError = isLaunchMode ? props.provisioningError : null;
const activeError = localError ?? modelValidationError ?? provisioningError;
const launchInFlight = useStore((s) =>
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
);
useEffect(() => {
if (!open || !isLaunch || !effectiveTeamName || !launchInFlight) {
if (!open || !isLaunchMode || !effectiveTeamName || !launchInFlight) {
return;
}
@ -1347,7 +1364,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
defaultProjectPath,
effectiveCwd,
effectiveTeamName,
isLaunch,
isLaunchMode,
launchInFlight,
open,
openTeamTab,
@ -1366,12 +1383,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setLocalError(modelValidationError);
return;
}
if (isLaunch && !effectiveCwd) {
if (isLaunchMode && !effectiveCwd) {
setLocalError('Select working directory (cwd)');
return;
}
if (
isLaunch &&
isLaunchMode &&
membersDrafts.some(
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
)
@ -1379,7 +1396,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setLocalError('Fix member names before launch');
return;
}
if (isLaunch) {
if (isLaunchMode) {
const activeNames = membersDrafts
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
@ -1393,11 +1410,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
void (async () => {
try {
if (isLaunch) {
await api.teams.replaceMembers(effectiveTeamName, {
members: buildMembersFromDrafts(effectiveMemberDrafts),
});
await props.onLaunch({
if (isLaunchMode) {
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts);
const launchRequest: TeamLaunchRequest = {
teamName: effectiveTeamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
@ -1417,7 +1432,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
});
};
if (isRelaunch) {
await props.onRelaunch(launchRequest, nextMembers);
} else {
await api.teams.replaceMembers(effectiveTeamName, {
members: nextMembers,
});
await props.onLaunch(launchRequest);
}
openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath);
closeDialog();
} else {
@ -1464,10 +1487,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
? err.message
: isSchedule
? 'Failed to save schedule'
: 'Failed to launch team';
: isRelaunch
? 'Failed to relaunch team'
: 'Failed to launch team';
setLocalError(message);
if (isLaunch) {
console.error('Failed to launch team from dialog:', err);
if (isLaunchMode) {
console.error(
isRelaunch
? 'Failed to relaunch team from dialog:'
: 'Failed to launch team from dialog:',
err
);
}
} finally {
setIsSubmitting(false);
@ -1479,7 +1509,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Disabled state
// ---------------------------------------------------------------------------
const isDisabled = isLaunch
const isDisabled = isLaunchMode
? isSubmitting ||
launchInFlight ||
validationErrors.length > 0 ||
@ -1492,13 +1522,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Dynamic labels
// ---------------------------------------------------------------------------
const dialogTitle = isLaunch ? 'Launch Team' : isEditing ? 'Edit Schedule' : 'Create Schedule';
const dialogTitle = isLaunchMode
? isRelaunch
? 'Relaunch Team'
: 'Launch Team'
: isEditing
? 'Edit Schedule'
: 'Create Schedule';
const dialogDescription = isLaunch ? (
<>
Start team <span className="font-mono font-medium">{effectiveTeamName}</span> via local Claude
CLI.
</>
const dialogDescription = isLaunchMode ? (
isRelaunch ? (
<>
Stop the current run for <span className="font-mono font-medium">{effectiveTeamName}</span>{' '}
and start it again via local Claude CLI.
</>
) : (
<>
Start team <span className="font-mono font-medium">{effectiveTeamName}</span> via local
Claude CLI.
</>
)
) : isEditing ? (
`Editing schedule for team "${effectiveTeamName}"`
) : effectiveTeamName ? (
@ -1507,15 +1550,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
'Schedule automatic Claude task execution'
);
const submitLabel = isLaunch
? prepareState === 'idle' || prepareState === 'loading'
? 'Skip and Launch'
: 'Launch'
const submitLabel = isLaunchMode
? isRelaunch
? 'Relaunch team'
: 'Launch team'
: isEditing
? 'Save Changes'
: 'Create Schedule';
const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...';
const submittingLabel = isLaunchMode
? isRelaunch
? 'Relaunching...'
: 'Launching...'
: isEditing
? 'Saving...'
: 'Creating...';
// ---------------------------------------------------------------------------
// Render
@ -1531,15 +1580,37 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}}
>
<DialogContent
className={isSchedule ? 'max-h-[90vh] max-w-2xl overflow-y-auto' : 'max-w-2xl'}
className={isSchedule ? 'max-h-[90vh] max-w-3xl overflow-y-auto' : 'max-w-3xl'}
>
<DialogHeader>
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
<DialogDescription className="text-xs">{dialogDescription}</DialogDescription>
</DialogHeader>
{isRelaunch ? (
<div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">Relaunch will restart the current team run</p>
<p className="opacity-80">
Saving these settings will stop the current team process, persist the updated
roster, and launch the team again with the new runtime.
</p>
</div>
</div>
</div>
) : null}
{/* Launch-only: Conflict warning */}
{isLaunch && conflictingTeam && !conflictDismissed ? (
{isLaunchMode && conflictingTeam && !conflictDismissed ? (
<div
className="rounded-md border p-3 text-xs"
style={{
@ -1714,10 +1785,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
Launch: optional settings
Schedule: prompt + execution defaults
*/}
{isLaunch ? (
{isLaunchMode ? (
<OptionalSettingsSection
title="Optional launch settings"
description="Keep the launch flow focused on the project path and only expand this when you want extra control."
title={isRelaunch ? 'Relaunch settings' : 'Optional launch settings'}
description={
isRelaunch
? 'Review the roster and lead runtime before restarting the team.'
: 'Keep the launch flow focused on the project path and only expand this when you want extra control.'
}
summary={launchOptionalSummary}
>
<div className="space-y-4">
@ -1964,9 +2039,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
) : null}
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
<DialogFooter className={isLaunchMode ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */}
{isLaunch ? (
{isLaunchMode ? (
<div className="min-w-0">
{prepareState === 'idle' || prepareState === 'loading' ? (
<>
@ -1980,7 +2055,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span>
<span>
Pre-flight check to catch errors before{' '}
{isRelaunch ? 'relaunch' : 'launch'}
</span>
</p>
</div>
</div>
@ -2023,13 +2101,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - launch is blocked
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
blocked
</p>
<p className="mt-0.5 text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
</p>
</div>
</div>
@ -2080,7 +2159,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={closeDialog}>
{isLaunch ? 'Close' : 'Cancel'}
{isLaunchMode ? 'Close' : 'Cancel'}
</Button>
<Button
size="sm"

View file

@ -8,6 +8,8 @@ import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { Check, FolderOpen } from 'lucide-react';
import { buildProjectPathOptions } from './projectPathOptions';
import type { Project } from '@shared/types';
function escapeRegExp(value: string): string {
@ -69,131 +71,134 @@ export const ProjectPathSelector = ({
projectsLoading,
projectsError,
fieldError,
}: ProjectPathSelectorProps): React.JSX.Element => (
<div className="space-y-1.5">
<Label>Project</Label>
<div className="space-y-2">
<div className="flex flex-col gap-2 md:flex-row md:items-start">
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('project')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('custom')}
>
Custom path
</button>
</div>
}: ProjectPathSelectorProps): React.JSX.Element => {
const projectOptions = React.useMemo(
() => buildProjectPathOptions(projects, selectedProjectPath),
[projects, selectedProjectPath]
);
<div className="min-w-0 flex-1">
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
return (
<div className="space-y-1.5">
<Label>Project</Label>
<div className="space-y-2">
<div className="flex flex-col gap-2 md:flex-row md:items-start">
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('project')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('custom')}
>
Custom path
</button>
</div>
<div className="min-w-0 flex-1">
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<Combobox
options={projectOptions}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projectOptions.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
</div>
</div>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projectOptions.length === 0 ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects found, switch to custom path.
</p>
) : null}
</div>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Input
className="h-8 flex-1 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
try {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
) : (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Input
className="h-8 flex-1 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
try {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
}
} catch {
// IPC error - dialog may have been cancelled or failed
}
} catch {
// IPC error — dialog may have been cancelled or failed
}
})();
}}
>
Browse
</Button>
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
)}
</div>
</div>
</div>
{fieldError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldError}
</p>
) : null}
</div>
{fieldError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldError}
</p>
) : null}
</div>
);
);
};

View file

@ -76,8 +76,8 @@ interface SendMessageDialogProps {
onClose: () => void;
}
// Sticky action mode — survives dialog close/reopen (component remount)
// Default: 'delegate' for teams (overridden to 'do' if solo/no teammates)
// Sticky action mode within the current session.
// Each dialog open still re-derives the default from the current team shape.
let stickyActionMode: ActionMode = 'delegate';
export const SendMessageDialog = ({
@ -168,7 +168,12 @@ export const SendMessageDialog = ({
useEffect(() => {
if (open && !prevOpenRef.current) {
const leadName = members.find((m) => isLeadMember(m))?.name;
setMember(defaultRecipient ?? leadName ?? '');
const nextRecipient = defaultRecipient ?? leadName ?? '';
const nextRecipientMember = members.find((candidate) => candidate.name === nextRecipient);
const nextCanDelegate =
members.length > 1 && Boolean(nextRecipientMember && isLeadMember(nextRecipientMember));
setMember(nextRecipient);
setActionMode(nextCanDelegate ? 'delegate' : 'do');
setQuote(quotedMessage);
setQuoteExpanded(false);
prevResultRef.current = lastResult;
@ -188,6 +193,8 @@ export const SendMessageDialog = ({
defaultChip,
quotedMessage,
lastResult,
members,
setActionMode,
textDraft,
chipDraft,
]);

View file

@ -38,6 +38,7 @@ import { useStore } from '@renderer/store';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
KANBAN_COLUMN_DISPLAY,
@ -149,6 +150,7 @@ export const TaskDetailDialog = ({
headerExtra,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
const { isLight } = useTheme();
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
@ -697,7 +699,10 @@ export const TaskDetailDialog = ({
style={reviewerBadgeStyle}
>
<img
src={agentAvatarUrl(currentTask.reviewer, 18)}
src={
avatarMap.get(currentTask.reviewer) ??
agentAvatarUrl(currentTask.reviewer, 18)
}
alt=""
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -0,0 +1,165 @@
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
function normalizeRestartSensitiveMemberContract(member: {
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}): {
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
} {
const role = member.role?.trim() || undefined;
const workflow = member.workflow?.trim() || undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = member.model?.trim() || undefined;
const effort =
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined;
return { role, workflow, providerId, model, effort };
}
export function getMemberRuntimeContractKey(member: {
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}): string {
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
}
export function getMembersRequiringRuntimeRestart(params: {
previousMembers: readonly ResolvedTeamMember[];
nextMembers: readonly TeamProvisioningMemberInput[];
}): string[] {
const previousByName = new Map(
params.previousMembers
.filter((member) => !member.removedAt)
.map((member) => [member.name.trim().toLowerCase(), member] as const)
);
const membersToRestart: string[] = [];
for (const nextMember of params.nextMembers) {
const normalizedName = nextMember.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
const previousMember = previousByName.get(normalizedName);
if (!previousMember) {
continue;
}
const previousRuntime = normalizeRestartSensitiveMemberContract(previousMember);
const nextRuntime = normalizeRestartSensitiveMemberContract(nextMember);
if (
previousRuntime.role !== nextRuntime.role ||
previousRuntime.workflow !== nextRuntime.workflow ||
previousRuntime.providerId !== nextRuntime.providerId ||
previousRuntime.model !== nextRuntime.model ||
previousRuntime.effort !== nextRuntime.effort
) {
membersToRestart.push(previousMember.name);
}
}
return membersToRestart;
}
export function getLiveRosterIdentityChanges(params: {
previousMembers: readonly ResolvedTeamMember[];
nextDrafts: readonly MemberDraft[];
}): {
renamed: string[];
removed: string[];
} {
const previousMembers = params.previousMembers
.filter((member) => !member.removedAt)
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead');
const previousNamesByKey = new Map(
previousMembers.map((member) => [member.name.trim().toLowerCase(), member.name.trim()] as const)
);
const nextExistingOriginalKeys = new Set(
params.nextDrafts
.map((member) => member.originalName?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
);
const renamed = params.nextDrafts
.flatMap((member) => {
const originalName = member.originalName?.trim();
const nextName = member.name.trim();
if (!originalName || !nextName) {
return [];
}
return originalName.toLowerCase() === nextName.toLowerCase() ? [] : [originalName];
})
.sort((a, b) => a.localeCompare(b));
const removed = Array.from(previousNamesByKey.entries())
.filter(([normalizedName]) => !nextExistingOriginalKeys.has(normalizedName))
.map(([, displayName]) => displayName)
.sort((a, b) => a.localeCompare(b));
return { renamed, removed };
}
function normalizeEditableMemberSnapshot(member: {
name: string;
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
removedAt?: number | string | null;
}): {
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
} | null {
if (member.removedAt) {
return null;
}
const name = member.name.trim();
if (!name || name.toLowerCase() === 'team-lead') {
return null;
}
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
...normalizeRestartSensitiveMemberContract(member),
};
}
export function buildEditTeamSourceSnapshot(params: {
name: string;
description: string;
color: string;
members: readonly ResolvedTeamMember[];
}): string {
const members = params.members
.map(normalizeEditableMemberSnapshot)
.filter((member): member is NonNullable<typeof member> => member !== null)
.sort((a, b) => a.name.localeCompare(b.name));
return JSON.stringify({
name: params.name.trim(),
description: params.description.trim(),
color: params.color.trim(),
members,
});
}

View file

@ -0,0 +1,45 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
import type { Project } from '@shared/types';
function toProjectOption(project: Project): ComboboxOption {
return {
value: project.path,
label: project.name,
description: project.path,
};
}
/**
* Collapse duplicate project entries that resolve to the same filesystem path.
* This keeps combobox item values unique even when scanner sources overlap.
*/
export function buildProjectPathOptions(
projects: Project[],
preferredPath?: string
): ComboboxOption[] {
const options: ComboboxOption[] = [];
const optionIndexByNormalizedPath = new Map<string, number>();
const normalizedPreferredPath = preferredPath ? normalizePath(preferredPath) : null;
for (const project of projects) {
const normalizedProjectPath = normalizePath(project.path);
const existingIndex = optionIndexByNormalizedPath.get(normalizedProjectPath);
if (existingIndex === undefined) {
optionIndexByNormalizedPath.set(normalizedProjectPath, options.length);
options.push(toProjectOption(project));
continue;
}
const shouldPreferCurrentOption =
normalizedPreferredPath === normalizedProjectPath && project.path === preferredPath;
if (shouldPreferCurrentOption) {
options[existingIndex] = toProjectOption(project);
}
}
return options;
}

View file

@ -0,0 +1,30 @@
import type { TeamCreateRequest, TeamLaunchRequest } from '@shared/types';
interface ExecuteTeamRelaunchOptions {
teamName: string;
isTeamAlive: boolean;
request: TeamLaunchRequest;
members: TeamCreateRequest['members'];
stopTeam: (teamName: string) => Promise<void>;
replaceMembers: (
teamName: string,
request: { members: TeamCreateRequest['members'] }
) => Promise<void>;
launchTeam: (request: TeamLaunchRequest) => Promise<unknown>;
}
export async function executeTeamRelaunch({
teamName,
isTeamAlive,
request,
members,
stopTeam,
replaceMembers,
launchTeam,
}: ExecuteTeamRelaunchOptions): Promise<void> {
if (isTeamAlive) {
await stopTeam(teamName);
}
await replaceMembers(teamName, { members });
await launchTeam(request);
}

View file

@ -0,0 +1,132 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
}));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
}));
vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'),
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamProviderLabel: (providerId: string) => providerId,
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
}) =>
React.createElement('input', {
...props,
checked,
type: 'checkbox',
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
React.createElement('label', props, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/utils/teamModelCatalog', () => ({
isAnthropicHaikuTeamModel: () => false,
}));
vi.mock('../../ui/button', () => ({
Button: ({
children,
className,
onClick,
disabled,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{ className, disabled, onClick, type: 'button', 'aria-label': ariaLabel },
children
),
}));
import { LeadModelRow } from './LeadModelRow';
function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(LeadModelRow, {
providerId: 'anthropic',
model: 'opus',
effort: 'medium',
limitContext: false,
onProviderChange: () => undefined,
onModelChange: () => undefined,
onEffortChange: () => undefined,
onLimitContextChange: () => undefined,
syncModelsWithTeammates: true,
onSyncModelsWithTeammatesChange: () => undefined,
})
);
});
return { host, root };
}
describe('LeadModelRow', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('uses the canonical team-lead color for the preview stripe', () => {
const { host, root } = renderLeadModelRow();
const stripe = host.querySelector('[aria-hidden="true"]');
const expectedBorder = getTeamColorSet(resolveTeamLeadColorName()).border;
expect(host.textContent).toContain('lead');
expect(host.textContent).toContain('Team Lead');
expect(stripe?.getAttribute('style')).toContain(expectedBorder);
act(() => {
root.unmount();
});
});
});

View file

@ -13,8 +13,9 @@ import { Label } from '@renderer/components/ui/label';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
import { Button } from '../../ui/button';
@ -54,7 +55,7 @@ export const LeadModelRow = ({
}: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false);
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
@ -63,7 +64,7 @@ export const LeadModelRow = ({
return (
<div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[auto_1fr_auto]"
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[minmax(0,1fr)_auto_auto]"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -77,9 +78,17 @@ export const LeadModelRow = ({
aria-hidden="true"
/>
<div className="min-w-0">
<div className="flex h-8 items-center gap-3 px-2">
<span className="text-sm font-medium text-[var(--color-text)]">lead</span>
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
<div className="flex items-center gap-2">
<img
src={agentAvatarUrl('team-lead', 32)}
alt=""
className="size-8 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<div className="flex h-8 min-w-0 items-center gap-3">
<span className="truncate text-sm font-medium text-[var(--color-text)]">lead</span>
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
</div>
</div>
</div>
<div className="min-w-0">

View file

@ -1,10 +1,15 @@
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -48,6 +53,26 @@ interface MemberCardProps {
onAssignTask?: () => void;
}
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
summary: string | undefined;
memory: string | undefined;
} {
const trimmed = runtimeSummary?.trim();
if (!trimmed) {
return { summary: undefined, memory: undefined };
}
const match = /^(.*?)(?:\s·\s(\d+(?:\.\d+)?\s(?:B|KB|MB|GB|TB)))$/.exec(trimmed);
if (!match) {
return { summary: trimmed, memory: undefined };
}
return {
summary: match[1]?.trim() || undefined,
memory: match[2]?.trim() || undefined,
};
}
export const MemberCard = ({
member,
memberColor,
@ -77,6 +102,11 @@ export const MemberCard = ({
// const leadContext = useStore((s) =>
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const launchPresentation = buildMemberLaunchPresentation({
member,
spawnStatus,
@ -102,6 +132,8 @@ export const MemberCard = ({
const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const { summary: runtimeSummaryText, memory: memoryLabel } =
splitRuntimeSummaryMemory(runtimeSummary);
const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -151,7 +183,7 @@ export const MemberCard = ({
}}
>
<img
src={agentAvatarUrl(member.name)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
@ -215,13 +247,19 @@ export const MemberCard = ({
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : runtimeSummary || roleLabel ? (
) : runtimeSummaryText || roleLabel || memoryLabel ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
{runtimeSummary && roleLabel ? (
{runtimeSummaryText ? (
<span className="min-w-0 truncate">{runtimeSummaryText}</span>
) : null}
{runtimeSummaryText && roleLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
</div>
) : null}
</div>

View file

@ -128,6 +128,9 @@ export const MemberDetailDialog = ({
: undefined,
[launchParams, member, runtimeEntry, spawnEntry]
);
const restartInFlight =
spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap';
useEffect(() => {
if (!open || !member) {
@ -267,7 +270,7 @@ export const MemberDetailDialog = ({
variant="outline"
size="sm"
className="gap-1.5"
disabled={restarting}
disabled={restarting || restartInFlight}
onClick={async () => {
setRestartError(null);
setRestarting(true);

View file

@ -1,11 +1,14 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -52,6 +55,11 @@ export const MemberDetailHeader = ({
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
@ -84,7 +92,7 @@ export const MemberDetailHeader = ({
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 96)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 96)}
alt={member.name}
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import {
formatTeamModelSummary,
getProviderScopedTeamModelLabel,
getTeamProviderLabel,
TeamModelSelector,
@ -29,6 +30,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
interface MemberDraftRowProps {
member: MemberDraft;
index: number;
avatarSrc?: string;
resolvedColor?: string;
nameError: string | null;
onNameChange: (id: string, name: string) => void;
@ -50,18 +52,30 @@ interface MemberDraftRowProps {
taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[];
lockProviderModel?: boolean;
lockRole?: boolean;
lockedRoleLabel?: string;
lockIdentity?: boolean;
identityLockReason?: string;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
isRemoved?: boolean;
onRestore?: (id: string) => void;
hideActionButton?: boolean;
warningText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
lockedModelAction?: {
label: string;
description?: string;
onClick: () => void;
disabled?: boolean;
};
}
export const MemberDraftRow = ({
member,
index,
avatarSrc,
resolvedColor,
nameError,
onNameChange,
@ -83,17 +97,24 @@ export const MemberDraftRow = ({
taskSuggestions,
teamSuggestions,
lockProviderModel = false,
lockRole = false,
lockedRoleLabel,
lockIdentity = false,
identityLockReason,
forceInheritedModelSettings = false,
modelLockReason,
isRemoved = false,
onRestore,
hideActionButton = false,
warningText,
disableGeminiOption = false,
modelIssueText,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
resolvedColor ?? getMemberColorByName(member.name.trim() || `member-${index}`)
resolvedColor ??
getMemberColorByName(member.originalName?.trim() || member.name.trim() || `member-${index}`)
);
const [workflowExpanded, setWorkflowExpanded] = useState(false);
const [modelExpanded, setModelExpanded] = useState(false);
@ -175,10 +196,16 @@ export const MemberDraftRow = ({
? `${modelButtonLabelBase} (lead)`
: modelButtonLabelBase;
const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: modelLockReason;
: (lockedModelAction?.description ?? modelLockReason);
const hasModelIssue = Boolean(modelIssueText);
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
effectiveModel?.trim() ?? '',
effectiveEffort
);
return (
<div
@ -196,33 +223,42 @@ export const MemberDraftRow = ({
aria-hidden="true"
/>
<div className="space-y-0.5">
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
disabled={isRemoved}
onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name"
style={
member.name.trim()
? {
color: memberColorSet.text,
}
: undefined
}
/>
<div className="flex items-center gap-2">
{avatarSrc ? (
<img
src={avatarSrc}
alt=""
className="size-8 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
) : null}
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
disabled={isRemoved || lockIdentity}
onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name"
/>
</div>
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
</div>
<div>
<RoleSelect
value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
triggerClassName="h-8 text-xs"
inputClassName="h-8 text-xs"
/>
{lockRole ? (
<div className="flex h-8 items-center rounded-md border border-[var(--color-border)] bg-transparent px-3 text-xs text-[var(--color-text)] opacity-80">
{lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
</div>
) : (
<RoleSelect
value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
triggerClassName="h-8 text-xs"
inputClassName="h-8 text-xs"
/>
)}
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
@ -258,7 +294,7 @@ export const MemberDraftRow = ({
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
)}
aria-label={modelButtonAriaLabel}
disabled={lockProviderModel || isRemoved}
disabled={(lockProviderModel && !canOpenLockedModelPanel) || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
@ -289,7 +325,7 @@ export const MemberDraftRow = ({
) : null}
</Tooltip>
</div>
{isRemoved ? (
{hideActionButton ? null : isRemoved ? (
<Button
variant="outline"
size="sm"
@ -358,36 +394,66 @@ export const MemberDraftRow = ({
) : null}
{modelExpanded && (
<div className="space-y-2 pl-3 md:col-span-3">
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {
if (lockProviderModel) return;
onProviderChange(member.id, providerId);
}}
value={effectiveModel ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onModelChange(member.id, value);
}}
id={`member-${member.id}-model`}
disableGeminiOption={disableGeminiOption}
modelIssueReasonByValue={
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">
{modelLockReason ??
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
{lockProviderModel && lockedModelAction ? (
<div className="space-y-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="space-y-1">
<p className="text-[11px] font-medium text-[var(--color-text)]">
Current lead runtime
</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{runtimeSummary}</p>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
{lockedModelAction.description ??
'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.'}
</p>
<p className="text-[11px] text-amber-300">
Saving those runtime changes restarts the whole team.
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="w-fit"
onClick={lockedModelAction.onClick}
disabled={lockedModelAction.disabled}
>
{lockedModelAction.label}
</Button>
</div>
) : (
<>
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {
if (lockProviderModel) return;
onProviderChange(member.id, providerId);
}}
value={effectiveModel ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onModelChange(member.id, value);
}}
id={`member-${member.id}-model`}
disableGeminiOption={disableGeminiOption}
modelIssueReasonByValue={
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">
{modelLockReason ??
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
)}
</>
)}
</div>
)}

View file

@ -18,6 +18,7 @@ import {
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
@ -92,6 +93,7 @@ export const MemberHoverCard = ({
}))
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const avatarMap = buildMemberAvatarMap(teamMembers);
if (!member) {
return <>{children}</>;
@ -142,7 +144,7 @@ export const MemberHoverCard = ({
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 64)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
alt={member.name}
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"

View file

@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { Plus } from 'lucide-react';
@ -88,10 +89,15 @@ export interface MembersEditorSectionProps {
hideContent?: boolean;
/** Existing team members — used to reserve their colors so drafts get the next available ones */
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
/** Pre-resolved member colors from the live Team view. */
existingMemberColorMap?: ReadonlyMap<string, string>;
/** Default provider to use for newly added member rows. */
defaultProviderId?: TeamProviderId;
/** When true, provider/model controls stay read-only for existing rows. */
lockProviderModel?: boolean;
/** When true, existing teammate names stay read-only while the team is live. */
lockExistingMemberIdentity?: boolean;
identityLockReason?: string;
inheritedProviderId?: TeamProviderId;
inheritedModel?: string;
inheritedEffort?: EffortLevel;
@ -102,6 +108,8 @@ export interface MembersEditorSectionProps {
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
memberModelIssueById?: Record<string, string | null | undefined>;
disableAddMember?: boolean;
addMemberLockReason?: string;
}
export const MembersEditorSection = ({
@ -118,8 +126,11 @@ export const MembersEditorSection = ({
headerExtra,
hideContent = false,
existingMembers,
existingMemberColorMap,
defaultProviderId = 'anthropic',
lockProviderModel = false,
lockExistingMemberIdentity = false,
identityLockReason,
inheritedProviderId,
inheritedModel,
inheritedEffort,
@ -130,6 +141,8 @@ export const MembersEditorSection = ({
memberWarningById,
disableGeminiOption = false,
memberModelIssueById,
disableAddMember = false,
addMemberLockReason,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
@ -257,8 +270,8 @@ export const MembersEditorSection = ({
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo(
() => buildMemberDraftColorMap(members, existingMembers),
[members, existingMembers]
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
[members, existingMembers, existingMemberColorMap]
);
const mentionSuggestions = useMemo(
@ -272,7 +285,14 @@ export const MembersEditorSection = ({
<Label>Members</Label>
{!hideContent && (
<div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={addMember}>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={addMember}
disabled={disableAddMember}
title={disableAddMember ? addMemberLockReason : undefined}
>
<Plus className="size-3.5" />
Add member
</Button>
@ -287,13 +307,17 @@ export const MembersEditorSection = ({
{headerExtra}
{!hideContent && (
<>
{disableAddMember && addMemberLockReason ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
) : null}
<div className="space-y-2">
{activeMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={index}
resolvedColor={memberColorMap.get(member.name.trim())}
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
resolvedColor={memberColorMap.get(member.id)}
nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
@ -315,6 +339,8 @@ export const MembersEditorSection = ({
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason}
modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}
@ -332,7 +358,8 @@ export const MembersEditorSection = ({
key={member.id}
member={member}
index={activeMembers.length + index}
resolvedColor={memberColorMap.get(member.name.trim())}
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
resolvedColor={memberColorMap.get(member.id)}
nameError={null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}

View file

@ -4,6 +4,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface MemberDraft {
id: string;
name: string;
originalName?: string;
roleSelection: string;
customRole: string;
workflow?: string;

View file

@ -1,29 +1,20 @@
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false;
if (!/^[a-zA-Z0-9]/.test(name)) return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}
export function validateMemberNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!isValidMemberName(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
return validateTeamMemberNameFormat(trimmed);
}
function newDraftId(): string {
@ -36,6 +27,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
return {
id: initial?.id ?? newDraftId(),
name: initial?.name ?? '',
originalName: initial?.originalName,
roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '',
workflow: initial?.workflow,
@ -66,6 +58,7 @@ export function createMemberDraftsFromInputs(
const isPreset = presetRoles.includes(role);
return createMemberDraft({
name: member.name,
originalName: member.name,
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: member.workflow,
@ -139,52 +132,62 @@ interface ExistingMemberColorInput {
removedAt?: number | string | null;
}
function getMemberDraftColorSeedKey(member: Pick<MemberDraft, 'id' | 'originalName'>): string {
const originalName = member.originalName?.trim();
return originalName || `draft:${member.id}`;
}
export function buildMemberDraftColorMap(
members: readonly Pick<MemberDraft, 'name'>[],
existingMembers?: readonly ExistingMemberColorInput[]
members: readonly Pick<MemberDraft, 'id' | 'name' | 'originalName'>[],
existingMembers?: readonly ExistingMemberColorInput[],
existingColorMap?: ReadonlyMap<string, string>
): Map<string, string> {
const draftEntries = members
.map((member) => member.name.trim())
.filter(Boolean)
.map((name) => ({ name }));
const normalizedExistingColorMap = new Map<string, string>(
Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [
name.trim().toLowerCase(),
color,
])
);
const existingSeedEntries = (existingMembers ?? [])
.map((member) => ({
...member,
name: member.name.trim(),
color: member.color?.trim() || getMemberColorByName(member.name),
color:
normalizedExistingColorMap.get(member.name.trim().toLowerCase()) ??
member.color?.trim() ??
undefined,
}))
.filter((member) => member.name);
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
const unseenNewDraftNames = new Set<string>();
const uniqueNewDraftEntries = draftEntries.filter((entry) => {
const normalizedName = entry.name.toLowerCase();
if (existingNames.has(normalizedName) || unseenNewDraftNames.has(normalizedName)) {
return false;
}
unseenNewDraftNames.add(normalizedName);
return true;
const uniqueNewDraftEntries = members
.filter((member) => {
if (member.originalName?.trim()) {
return false;
}
const currentName = member.name.trim();
return !currentName || !existingNames.has(currentName.toLowerCase());
})
.map((member) => ({ name: getMemberDraftColorSeedKey(member) }));
const fullMap = buildTeamMemberColorMap([...existingSeedEntries, ...uniqueNewDraftEntries], {
preferProvidedColors: true,
});
const predictedDraftSeedEntries = uniqueNewDraftEntries.map((entry) => ({
...entry,
color: getMemberColorByName(entry.name),
}));
// Mirror the team page color inputs:
// 1. existing members keep their persisted/resolved color
// 2. new draft members get the same name-based default color that the resolver
// will assign after create/launch/add
// 3. buildMemberColorMap still resolves rare collisions the same way as the UI
const fullMap = buildMemberColorMap([...existingSeedEntries, ...predictedDraftSeedEntries]);
const fullColorByName = new Map(
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
);
const draftMap = new Map<string, string>();
for (const entry of draftEntries) {
const color = fullColorByName.get(entry.name.toLowerCase());
if (color) draftMap.set(entry.name, color);
for (const member of members) {
const originalName = member.originalName?.trim();
const currentName = member.name.trim();
const colorSeedKey = originalName
? originalName
: currentName && existingNames.has(currentName.toLowerCase())
? currentName
: getMemberDraftColorSeedKey(member);
const color = fullColorByName.get(colorSeedKey.toLowerCase());
if (color) draftMap.set(member.id, color);
}
return draftMap;
}
@ -209,7 +212,7 @@ export function buildMemberDraftSuggestions(
id: m.id,
name: m.name.trim(),
subtitle: getMemberDraftRole(m),
color: colorMap.get(m.name.trim()) ?? undefined,
color: colorMap.get(m.id) ?? undefined,
}));
}

View file

@ -282,6 +282,9 @@ export const MessageComposer = ({
if (!isInitializedRef.current) {
isInitializedRef.current = true;
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
if (shouldAutoDelegate && actionMode === 'do') {
setActionMode('delegate');
}
return;
}

View file

@ -1,4 +1,13 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
memo,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet';
import { Badge } from '@renderer/components/ui/badge';
@ -27,7 +36,7 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from '../activity/ActivityTimeline';
import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline';
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
@ -91,6 +100,67 @@ interface MessagesPanelProps {
onRestartTeam?: () => void;
/** Callback when a task ID link is clicked. */
onTaskIdClick?: (taskId: string) => void;
/**
* Scroll container owned by the parent view when `position === 'inline'`.
* MessagesPanel does not own this element the viewport lives in
* TeamDetailView's content scroll area. Plumbed for future viewport
* consumers (virtualization); unused in this release.
*/
inlineScrollContainerRef?: RefObject<HTMLDivElement | null>;
}
export function reconcilePendingRepliesByMember(
pendingRepliesByMember: Record<string, number>,
messages: InboxMessage[]
): Record<string, number> {
if (Object.keys(pendingRepliesByMember).length === 0) {
return pendingRepliesByMember;
}
const latestUserSentByMember = new Map<string, number>();
const latestReplyToUserByMember = new Map<string, number>();
for (const message of messages) {
const ts = Date.parse(message.timestamp);
if (!Number.isFinite(ts)) {
continue;
}
if (
message.from === 'user' &&
typeof message.to === 'string' &&
message.to.length > 0 &&
message.source === 'user_sent'
) {
const previous = latestUserSentByMember.get(message.to);
if (previous == null || ts > previous) {
latestUserSentByMember.set(message.to, ts);
}
continue;
}
if (message.to === 'user') {
const previous = latestReplyToUserByMember.get(message.from);
if (previous == null || ts > previous) {
latestReplyToUserByMember.set(message.from, ts);
}
}
}
let changed = false;
const next: Record<string, number> = {};
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const latestReplyAt = latestReplyToUserByMember.get(memberName);
const latestDurableSendAt = latestUserSentByMember.get(memberName);
const threshold = latestDurableSendAt ?? sentAtMs;
if (latestReplyAt != null && latestReplyAt > threshold) {
changed = true;
continue;
}
next[memberName] = sentAtMs;
}
return changed ? next : pendingRepliesByMember;
}
export const MessagesPanel = memo(function MessagesPanel({
@ -113,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onReplyToMessage,
onRestartTeam,
onTaskIdClick,
inlineScrollContainerRef,
}: MessagesPanelProps): React.JSX.Element {
const {
sendTeamMessage,
@ -125,6 +196,7 @@ export const MessagesPanel = memo(function MessagesPanel({
messages,
messagesState,
loadOlderTeamMessages,
refreshTeamMessagesHead,
} = useStore(
useShallow((s) => ({
sendTeamMessage: s.sendTeamMessage,
@ -137,8 +209,10 @@ export const MessagesPanel = memo(function MessagesPanel({
messages: selectTeamMessages(s, teamName),
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
loadOlderTeamMessages: s.loadOlderTeamMessages,
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
}))
);
const bootstrapHeadRefreshAttemptedForTeamRef = useRef<string | null>(null);
const loadOlderMessages = useCallback(async () => {
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
@ -157,6 +231,36 @@ export const MessagesPanel = memo(function MessagesPanel({
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
const bottomSheetRef = useRef<SheetRef>(null);
const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(null);
// Scroll container inside `Sheet.Content` for the bottom-sheet layout.
// react-modal-sheet merges this ref with its own internal scroll ref.
// Held here so future viewport consumers (virtualization) can observe the
// true scrolling element in bottom-sheet mode.
const bottomSheetScrollRef = useRef<HTMLDivElement | null>(null);
// Resolve the active scroll owner for the current layout. This is the
// ref that ActivityTimeline's IntersectionObserver will use as its root,
// so visibility is measured against the real scroll container rather
// than the document viewport. Virtualizer consumers will hook into the
// same ref in a follow-up change.
const activeScrollContainerRef =
position === 'inline'
? (inlineScrollContainerRef ?? null)
: position === 'sidebar'
? sidebarScrollRef
: bottomSheetScrollRef;
const activityTimelineViewport = useMemo<TimelineViewport | undefined>(() => {
if (!activeScrollContainerRef) return undefined;
return {
scrollElementRef: activeScrollContainerRef,
observerRoot: activeScrollContainerRef,
scrollMargin: 0,
// Opt into virtualization; ActivityTimeline keeps the direct render
// path for short lists and only switches to the windowed path once
// the row count crosses its internal threshold.
virtualizationEnabled: true,
};
}, [activeScrollContainerRef]);
const handleExpandContent = useCallback(() => {
// no-op: user is reading expanded content, not composing
}, []);
@ -224,6 +328,41 @@ export const MessagesPanel = memo(function MessagesPanel({
bottomSheetSnapIndex,
]);
useEffect(() => {
const hasActiveParticipantFilter = messagesFilter.from.size > 0 || messagesFilter.to.size > 0;
if (
messagesSearchBarVisible ||
(messagesSearchQuery.trim().length === 0 && !hasActiveParticipantFilter)
) {
return;
}
setMessagesSearchBarVisible(true);
}, [messagesFilter.from, messagesFilter.to, messagesSearchBarVisible, messagesSearchQuery]);
useEffect(() => {
if (!teamName) {
return;
}
if (effectiveMessages.length > 0) {
bootstrapHeadRefreshAttemptedForTeamRef.current = null;
return;
}
if (messagesState?.loadingHead || messagesState?.loadingOlder) {
return;
}
if (bootstrapHeadRefreshAttemptedForTeamRef.current === teamName) {
return;
}
bootstrapHeadRefreshAttemptedForTeamRef.current = teamName;
void refreshTeamMessagesHead(teamName).catch(() => undefined);
}, [
effectiveMessages.length,
messagesState?.loadingHead,
messagesState?.loadingOlder,
refreshTeamMessagesHead,
teamName,
]);
useLayoutEffect(() => {
if (position !== 'sidebar') return;
const el = sidebarScrollRef.current;
@ -352,20 +491,8 @@ export const MessagesPanel = memo(function MessagesPanel({
// Auto-clear pending replies when a member actually responds
useEffect(() => {
if (Object.keys(pendingRepliesByMember).length === 0) return;
const next = { ...pendingRepliesByMember };
let changed = false;
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const hasReply = replyCandidateMessages.some((m) => {
if (m.from !== memberName) return false;
const ts = Date.parse(m.timestamp);
return Number.isFinite(ts) && ts > sentAtMs;
});
if (hasReply) {
delete next[memberName];
changed = true;
}
}
if (changed) onPendingReplyChange(() => next);
const next = reconcilePendingRepliesByMember(pendingRepliesByMember, replyCandidateMessages);
if (next !== pendingRepliesByMember) onPendingReplyChange(() => next);
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
const handleSend = useCallback(
@ -581,6 +708,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
@ -766,6 +894,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
@ -987,6 +1116,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<Sheet.Content
className="min-h-0 bg-[var(--color-surface-sidebar)]"
scrollClassName="flex min-h-full flex-col"
scrollRef={bottomSheetScrollRef}
disableDrag={(state) => state.scrollPosition !== 'top'}
>
<div
@ -1052,6 +1182,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">

View file

@ -23,6 +23,7 @@ export interface TeamClaudeLogsSidebarUiState {
}
const messagesStateByTeam = new Map<string, TeamMessagesSidebarUiState>();
const pendingRepliesStateByTeam = new Map<string, Record<string, number>>();
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState {
@ -101,6 +102,17 @@ export function setTeamMessagesSidebarUiState(
});
}
export function getTeamPendingRepliesState(teamName: string): Record<string, number> {
return { ...(pendingRepliesStateByTeam.get(teamName) ?? {}) };
}
export function setTeamPendingRepliesState(
teamName: string,
pendingRepliesByMember: Record<string, number>
): void {
pendingRepliesStateByTeam.set(teamName, { ...pendingRepliesByMember });
}
export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState {
const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState();
return {

View file

@ -4,7 +4,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
} from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
@ -43,6 +47,7 @@ export const MemberSelect = ({
const { isLight } = useTheme();
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = React.useMemo(() => buildMemberAvatarMap(members), [members]);
const selectedMember = React.useMemo(
() => (value ? members.find((m) => m.name === value) : null),
[members, value]
@ -60,7 +65,7 @@ export const MemberSelect = ({
return (
<span className="inline-flex items-center gap-1.5">
<img
src={agentAvatarUrl(member.name, avatarSize)}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
@ -176,7 +181,7 @@ export const MemberSelect = ({
className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<img
src={agentAvatarUrl(m.name, avatarSize)}
src={avatarMap.get(m.name) ?? agentAvatarUrl(m.name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"

View file

@ -30,6 +30,83 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
text: '#60a5fa',
textLight: '#2563eb',
},
saffron: {
border: '#eab308',
badge: 'rgba(234, 179, 8, 0.15)',
badgeLight: 'rgba(234, 179, 8, 0.12)',
text: '#fde047',
textLight: '#a16207',
},
turquoise: {
border: '#14b8a6',
badge: 'rgba(20, 184, 166, 0.15)',
badgeLight: 'rgba(20, 184, 166, 0.12)',
text: '#5eead4',
textLight: '#0f766e',
},
brick: {
border: '#ef4444',
badge: 'rgba(239, 68, 68, 0.15)',
badgeLight: 'rgba(239, 68, 68, 0.12)',
text: '#f87171',
textLight: '#b91c1c',
},
indigo: {
border: '#8b5cf6',
badge: 'rgba(139, 92, 246, 0.15)',
badgeLight: 'rgba(139, 92, 246, 0.12)',
text: '#c4b5fd',
textLight: '#6d28d9',
},
forest: {
border: '#22c55e',
badge: 'rgba(34, 197, 94, 0.15)',
badgeLight: 'rgba(34, 197, 94, 0.12)',
text: '#86efac',
textLight: '#15803d',
},
apricot: {
border: '#fb923c',
badge: 'rgba(251, 146, 60, 0.15)',
badgeLight: 'rgba(251, 146, 60, 0.12)',
text: '#fdba74',
textLight: '#c2410c',
},
rose: {
border: '#f43f5e',
badge: 'rgba(244, 63, 94, 0.15)',
badgeLight: 'rgba(244, 63, 94, 0.12)',
text: '#fda4af',
textLight: '#be123c',
},
cerulean: {
border: '#38bdf8',
badge: 'rgba(56, 189, 248, 0.15)',
badgeLight: 'rgba(56, 189, 248, 0.12)',
text: '#7dd3fc',
textLight: '#0369a1',
},
olive: {
border: '#84cc16',
badge: 'rgba(132, 204, 22, 0.15)',
badgeLight: 'rgba(132, 204, 22, 0.12)',
text: '#bef264',
textLight: '#4d7c0f',
},
copper: {
border: '#b45309',
badge: 'rgba(180, 83, 9, 0.15)',
badgeLight: 'rgba(180, 83, 9, 0.12)',
text: '#fdba74',
textLight: '#92400e',
},
steel: {
border: '#64748b',
badge: 'rgba(100, 116, 139, 0.15)',
badgeLight: 'rgba(100, 116, 139, 0.12)',
text: '#cbd5e1',
textLight: '#475569',
},
green: {
border: '#22c55e',
badge: 'rgba(34, 197, 94, 0.15)',

View file

@ -116,15 +116,7 @@ function noteTeamChangeEventBurst(teamName: string, eventType: string, visible:
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}`
);
// Disabled - this warning is too noisy during normal inbox bursts on active teams.
}
teamChangeEventDiagnostics.set(teamName, diagnostic);

View file

@ -10,6 +10,16 @@ import type { Session, SessionSortMode } from '@renderer/types/data';
import type { StateCreator } from 'zustand';
const logger = createLogger('Store:session');
const SESSION_IN_PLACE_RETRY_DELAY_MS = 150;
function isTransientSessionsPaginatedIpcError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '');
return (
message.includes(
"Error invoking remote method 'get-sessions-paginated': reply was never sent"
) || message.includes("No handler registered for 'get-sessions-paginated'")
);
}
/**
* Tracks the latest in-place refresh generation per project.
@ -257,20 +267,21 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
projectRefreshGeneration.set(projectId, generation);
try {
const fetchPage = async () => {
const { connectionMode } = get();
const result = await api.getSessionsPaginated(projectId, null, 20, {
return api.getSessionsPaginated(projectId, null, 20, {
includeTotalCount: false,
prefilterAll: false,
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
});
};
const applyResult = (result: Awaited<ReturnType<typeof api.getSessionsPaginated>>) => {
// Drop stale responses from older in-flight refreshes
if (projectRefreshGeneration.get(projectId) !== generation) {
return;
}
// Update sessions without loading state
set({
sessions: result.sessions,
sessionsCursor: result.nextCursor,
@ -278,7 +289,27 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
sessionsTotalCount: result.totalCount,
// Don't touch sessionsLoading - keep it as-is
});
};
try {
const result = await fetchPage();
applyResult(result);
} catch (error) {
if (isTransientSessionsPaginatedIpcError(error) && get().selectedProjectId === projectId) {
logger.warn('refreshSessionsInPlace transient IPC error - retrying once');
try {
await new Promise((resolve) => setTimeout(resolve, SESSION_IN_PLACE_RETRY_DELAY_MS));
if (get().selectedProjectId !== projectId) {
return;
}
const retried = await fetchPage();
applyResult(retried);
return;
} catch (retryError) {
logger.error('refreshSessionsInPlace retry error:', retryError);
return;
}
}
logger.error('refreshSessionsInPlace error:', error);
// Don't set error state - this is a background refresh
}

View file

@ -4180,11 +4180,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
restartMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
await Promise.all([
get().fetchMemberSpawnStatuses(teamName),
get().fetchTeamAgentRuntime(teamName),
]);
try {
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
} finally {
await Promise.allSettled([
get().fetchMemberSpawnStatuses(teamName),
get().fetchTeamAgentRuntime(teamName),
]);
}
},
removeMember: async (teamName: string, memberName: string) => {

View file

@ -0,0 +1,38 @@
import avatar01 from '@renderer/assets/participant-avatars/01.png';
import avatar02 from '@renderer/assets/participant-avatars/02.png';
import avatar03 from '@renderer/assets/participant-avatars/03.png';
import avatar04 from '@renderer/assets/participant-avatars/04.png';
import avatar05 from '@renderer/assets/participant-avatars/05.png';
import avatar06 from '@renderer/assets/participant-avatars/06.png';
import avatar07 from '@renderer/assets/participant-avatars/07.png';
import avatar08 from '@renderer/assets/participant-avatars/08.png';
import avatar09 from '@renderer/assets/participant-avatars/09.png';
import avatar10 from '@renderer/assets/participant-avatars/10.png';
import avatar11 from '@renderer/assets/participant-avatars/11.png';
import avatar12 from '@renderer/assets/participant-avatars/12.png';
import avatar13 from '@renderer/assets/participant-avatars/13.png';
export const PARTICIPANT_AVATAR_URLS = [
avatar01,
avatar02,
avatar03,
avatar04,
avatar05,
avatar06,
avatar07,
avatar08,
avatar09,
avatar10,
avatar11,
avatar12,
avatar13,
] as const;
export const LEAD_PARTICIPANT_AVATAR_URL = PARTICIPANT_AVATAR_URLS[0];
export function getParticipantAvatarUrlByIndex(index: number): string {
const normalized =
((Math.trunc(index) % PARTICIPANT_AVATAR_URLS.length) + PARTICIPANT_AVATAR_URLS.length) %
PARTICIPANT_AVATAR_URLS.length;
return PARTICIPANT_AVATAR_URLS[normalized];
}

View file

@ -1,10 +1,12 @@
import {
getMemberColorByName,
MEMBER_COLOR_PALETTE,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
getParticipantAvatarUrlByIndex,
LEAD_PARTICIPANT_AVATAR_URL,
PARTICIPANT_AVATAR_URLS,
} from './memberAvatarCatalog';
import type {
LeadActivityState,
MemberLaunchState,
@ -27,8 +29,26 @@ export function displayMemberName(name: string): string {
return name === 'team-lead' ? 'lead' : name;
}
function hashStringToIndex(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
export function agentAvatarUrl(name: string, size = 64): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
void size;
const normalized = name.trim().toLowerCase();
if (normalized === 'team-lead' || normalized === 'lead') {
return LEAD_PARTICIPANT_AVATAR_URL;
}
// Temporarily disabled external avatar API.
// return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
return getParticipantAvatarUrlByIndex(
hashStringToIndex(normalized) % PARTICIPANT_AVATAR_URLS.length
);
}
export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
@ -571,6 +591,12 @@ interface MemberColorInput {
role?: string;
}
interface MemberAvatarInput {
name: string;
removedAt?: number | string | null;
agentType?: string;
}
/**
* Build a consistent namecolorName map for all members.
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
@ -579,44 +605,52 @@ interface MemberColorInput {
* Maps "user" to a reserved color.
*/
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
}
export function buildMemberAvatarMap(members: readonly MemberAvatarInput[]): Map<string, string> {
const map = new Map<string, string>();
const active = members.filter((m) => !m.removedAt);
const removed = members.filter((m) => m.removedAt);
const usedColors = new Set<string>();
let nextPaletteIdx = 0;
const activeMembers = members.filter((member) => !member.removedAt);
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
for (const member of active) {
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
// Assign the next unused color from the pre-ordered palette.
while (
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
) {
nextPaletteIdx++;
}
color =
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
nextPaletteIdx++;
for (const [index, member] of leadMembers.entries()) {
map.set(member.name, index === 0 ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name));
}
for (const [index, member] of teammateMembers.entries()) {
map.set(
member.name,
getParticipantAvatarUrlByIndex(1 + (index % (PARTICIPANT_AVATAR_URLS.length - 1)))
);
}
for (const member of members) {
if (!map.has(member.name)) {
map.set(
member.name,
isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name)
);
}
map.set(member.name, color);
usedColors.add(color);
}
for (const member of removed) {
const color = member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
}
map.set('user', 'user');
map.set('user', agentAvatarUrl('user'));
map.set('system', agentAvatarUrl('system'));
return map;
}
export function resolveMemberAvatarUrl(
member: MemberAvatarInput,
avatarMap?: ReadonlyMap<string, string>,
size = 64
): string {
return (
avatarMap?.get(member.name) ??
(isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name, size))
);
}
export const KANBAN_COLUMN_DISPLAY: Record<
'review' | 'approved',
{ label: string; bg: string; text: string }

View file

@ -6,36 +6,40 @@
* Intentionally excludes purple-family tones.
*/
export const MEMBER_COLOR_PALETTE = [
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
'blue', // 0°
'saffron', // 177°
'turquoise', // 268°
'brick', // 85°
'apricot', // 131°
'indigo', // 314°
'forest', // 223°
'pink', // 39°
'crimson', // 59°
'tangerine', // 105°
// ── First 12: intentionally distinct visual families for roster readability ──
'blue',
'saffron',
'turquoise',
'brick',
'indigo',
'forest',
'apricot',
'rose',
'cerulean',
'olive',
'copper',
'steel',
// ── Next 14: still good separation ──
'gold', // 151°
'emerald', // 203°
'cerulean', // 288°
'denim', // 334°
'cyan', // 20°
'sage', // 242°
'tomato', // 72°
'rust', // 118°
'mustard', // 164°
'canary', // 190°
'teal', // 255°
'arctic', // 301°
'royal', // 347°
'green', // 7°
// ── Next 12: secondary accents after the core distinct set ──
'gold',
'emerald',
'cobalt',
'crimson',
'tangerine',
'denim',
'cyan',
'sage',
'tomato',
'rust',
'mustard',
'canary',
'teal',
'arctic',
'royal',
// ── Remaining: fill the hue gaps progressively ──
'rose', // 46°
'green',
'pink',
'ruby', // 92°
'sienna', // 144°
'mint', // 216°
@ -49,90 +53,45 @@ export const MEMBER_COLOR_PALETTE = [
'salmon', // 79°
'amber', // 98°
'peach', // 111°
'copper', // 124°
'bronze', // 137°
'lemon', // 157°
'honey', // 170°
'marigold', // 183°
'sunflower', // 196°
'lime', // 209°
'olive', // 229°
'jade', // 236°
'chartreuse', // 249°
'aqua', // 262°
'azure', // 281°
'seafoam', // 295°
'cobalt', // 308°
'periwinkle', // 327°
'steel', // 340°
'cornflower', // 353°
] as const;
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
/**
* Fixed hue angle (0-359) for each palette color name.
* This is independent of array order colors keep their visual identity
* regardless of how MEMBER_COLOR_PALETTE is sorted.
* Spread evenly across 360° so every name has a unique hue.
* Canonical runtime/member id for the team lead.
* UI surfaces should use this exact key when deriving the lead color so
* previews match the resolved team roster.
*/
export const MEMBER_COLOR_HUE: Record<string, number> = {
blue: 0,
green: 7,
yellow: 13,
cyan: 20,
red: 26,
orange: 33,
pink: 39,
rose: 46,
coral: 52,
crimson: 59,
scarlet: 65,
tomato: 72,
salmon: 79,
brick: 85,
ruby: 92,
amber: 98,
tangerine: 105,
peach: 111,
rust: 118,
copper: 124,
apricot: 131,
bronze: 137,
sienna: 144,
gold: 151,
lemon: 157,
mustard: 164,
honey: 170,
saffron: 177,
marigold: 183,
canary: 190,
sunflower: 196,
emerald: 203,
lime: 209,
mint: 216,
forest: 223,
olive: 229,
jade: 236,
sage: 242,
chartreuse: 249,
teal: 255,
aqua: 262,
turquoise: 268,
sky: 275,
azure: 281,
cerulean: 288,
seafoam: 295,
arctic: 301,
cobalt: 308,
indigo: 314,
sapphire: 321,
periwinkle: 327,
denim: 334,
steel: 340,
royal: 347,
cornflower: 353,
};
export const TEAM_LEAD_MEMBER_COLOR_ID = 'team-lead' as const;
/**
* Fixed hue angle (0-359) for each palette color name.
* The first roster-assigned colors are intentionally spaced far apart so the
* first 10-12 teammates in a team remain visually distinct.
*/
const MEMBER_COLOR_HUES_BY_ORDER = [
240, 60, 180, 0, 120, 300, 30, 210, 330, 90, 150, 270, 15, 195, 105, 285, 45, 225, 135, 315, 75,
255, 165, 345, 7.5, 187.5, 97.5, 277.5, 37.5, 217.5, 127.5, 307.5, 67.5, 247.5, 157.5, 337.5,
22.5, 202.5, 112.5, 292.5, 52.5, 232.5, 142.5, 322.5, 82.5, 262.5, 172.5, 352.5, 11.25, 191.25,
101.25, 281.25, 41.25, 221.25, 131.25,
] as const;
export const MEMBER_COLOR_HUE: Record<string, number> = Object.fromEntries(
MEMBER_COLOR_PALETTE.map((colorName, index) => [colorName, MEMBER_COLOR_HUES_BY_ORDER[index]])
) as Record<string, number>;
const DISALLOWED_MEMBER_COLORS = new Set([
'purple',

View file

@ -0,0 +1,107 @@
import {
getMemberColorByName,
MEMBER_COLOR_PALETTE,
TEAM_LEAD_MEMBER_COLOR_ID,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
export interface TeamMemberColorInput {
name: string;
color?: string;
removedAt?: number | string | null;
agentType?: string;
}
interface BuildTeamMemberColorMapOptions {
preferProvidedColors?: boolean;
}
/**
* Build a deterministic roster color map that optimizes contrast inside a team.
* Leads reserve their own color but do not consume the teammate palette order.
*/
export function buildTeamMemberColorMap(
members: readonly TeamMemberColorInput[],
options: BuildTeamMemberColorMapOptions = {}
): Map<string, string> {
const preferProvidedColors = options.preferProvidedColors ?? true;
const map = new Map<string, string>();
const active = members.filter((member) => !member.removedAt);
const removed = members.filter((member) => member.removedAt);
const activeLeads = active.filter((member) => isLeadMember(member));
const activeTeammates = active.filter((member) => !isLeadMember(member));
const usedColors = new Set<string>();
let nextPaletteIdx = 0;
for (const member of activeLeads) {
const color =
preferProvidedColors && member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
usedColors.add(color);
}
for (const member of activeTeammates) {
let color =
preferProvidedColors && member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
while (
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
) {
nextPaletteIdx += 1;
}
color =
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
: MEMBER_COLOR_PALETTE[activeTeammates.indexOf(member) % MEMBER_COLOR_PALETTE.length];
nextPaletteIdx += 1;
}
map.set(member.name, color);
usedColors.add(color);
}
for (const member of removed) {
const color =
preferProvidedColors && member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
}
map.set('user', 'user');
return map;
}
/**
* Resolve the visual color for a standalone member preview by reusing the same
* roster color pipeline that powers the team screen.
*/
export function resolveTeamMemberColorName(
member: TeamMemberColorInput,
options: BuildTeamMemberColorMapOptions = {}
): string {
const color = buildTeamMemberColorMap([member], options).get(member.name);
if (color) {
return color;
}
if (options.preferProvidedColors !== false && member.color) {
return normalizeMemberColorName(member.color);
}
return getMemberColorByName(member.name);
}
export function resolveTeamLeadColorName(): string {
return resolveTeamMemberColorName(
{
name: TEAM_LEAD_MEMBER_COLOR_ID,
agentType: 'team-lead',
},
{ preferProvidedColors: false }
);
}

View file

@ -8,6 +8,21 @@ export function parseNumericSuffixName(name: string): { base: string; suffix: nu
return { base: match[1], suffix };
}
export function validateTeamMemberNameFormat(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (trimmed.length < 1 || trimmed.length > 128) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
if (!/^[a-zA-Z0-9]/.test(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
}
/**
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
* (e.g. "alice" "alice-2"). We treat "-2+" as an auto-suffix only when the base

View file

@ -9,7 +9,8 @@ export type AgentToolDuplicateSkipReason = 'already_running' | 'bootstrap_pendin
export interface ParsedAgentToolResultStatus {
status: 'duplicate_skipped';
reason: AgentToolDuplicateSkipReason;
reason?: AgentToolDuplicateSkipReason;
rawReason?: string;
name?: string;
teamName?: string;
}
@ -262,14 +263,14 @@ export function parseAgentToolResultStatus(content: unknown): ParsedAgentToolRes
return null;
}
const reason = fields.get('reason');
if (reason !== 'already_running' && reason !== 'bootstrap_pending') {
return null;
}
const rawReason = fields.get('reason');
const reason =
rawReason === 'already_running' || rawReason === 'bootstrap_pending' ? rawReason : undefined;
return {
status: 'duplicate_skipped',
reason,
...(rawReason ? { rawReason } : {}),
name: fields.get('name'),
teamName: fields.get('team_name'),
};

View file

@ -172,6 +172,7 @@ describe('ipc teams handlers', () => {
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
@ -1732,6 +1733,47 @@ describe('ipc teams handlers', () => {
});
});
describe('updateConfig', () => {
it('notifies a live lead only when the team name actually changes', async () => {
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
provisioningService.isTeamAlive = vi.fn(() => true);
const result = (await handler({} as never, 'my-team', {
name: 'Renamed Team',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
name: 'Renamed Team',
description: undefined,
color: undefined,
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
);
});
it('does not notify the lead when the submitted team name is unchanged', async () => {
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
provisioningService.isTeamAlive = vi.fn(() => true);
const result = (await handler({} as never, 'my-team', {
name: 'My Team',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
name: 'My Team',
description: undefined,
color: undefined,
});
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
});
describe('removeMember', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;

View file

@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { SendMessageResult, TaskRef, TeamSummary } from '../../../../src/shared/types';
function createService(configReaderOverrides: Record<string, unknown> = {}): TeamDataService {
return new TeamDataService(
{
getConfig: vi.fn(async () => null),
listTeams: vi.fn(async () => []),
...configReaderOverrides,
} as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'demo', reviewers: [], tasks: {} })) } as never,
{} as never,
{ getMembers: vi.fn(async () => []), writeMembers: vi.fn(async () => {}) } as never,
{ readMessages: vi.fn(async () => []) } as never
);
}
describe('TeamDataService stall-monitor helpers', () => {
it('lists alive process teams using non-stopped processes and ignores per-team read errors', async () => {
const teams: TeamSummary[] = [
{
teamName: 'beta',
displayName: 'beta',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
{
teamName: 'alpha',
displayName: 'alpha',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
{
teamName: 'gamma',
displayName: 'gamma',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
{
teamName: 'deleted',
displayName: 'deleted',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
deletedAt: '2026-04-19T12:09:00.000Z',
},
];
const service = createService({
listTeams: vi.fn(async () => teams),
});
const readProcesses = vi.fn(async (teamName: string) => {
if (teamName === 'alpha') {
return [{ id: '1', label: 'alpha', pid: 101, registeredAt: '2026-04-19T12:00:00.000Z' }];
}
if (teamName === 'beta') {
return [
{
id: '2',
label: 'beta',
pid: 202,
registeredAt: '2026-04-19T12:00:00.000Z',
stoppedAt: '2026-04-19T12:05:00.000Z',
},
];
}
if (teamName === 'deleted') {
return [{ id: '9', label: 'deleted', pid: 909, registeredAt: '2026-04-19T12:00:00.000Z' }];
}
throw new Error('boom');
});
(service as unknown as { readProcesses: typeof readProcesses }).readProcesses = readProcesses;
await expect(service.listAliveProcessTeams()).resolves.toEqual(['alpha']);
expect(readProcesses).not.toHaveBeenCalledWith('deleted');
});
it('routes system notifications to the resolved lead via sendMessage', async () => {
const leadTaskRef: TaskRef = {
taskId: 'task-1',
displayId: '1',
teamName: 'demo',
};
const service = createService({
getConfig: vi.fn(async () => ({
name: 'demo',
members: [{ name: 'lead', role: 'Team Lead' }],
})),
});
const expectedResult = { messageId: 'msg-1' } as SendMessageResult;
const sendMessageSpy = vi.spyOn(service, 'sendMessage').mockResolvedValue(expectedResult);
await expect(
service.sendSystemNotificationToLead({
teamName: 'demo',
summary: 'Potential stalled tasks detected',
text: 'Task #1 looks stalled.',
taskRefs: [leadTaskRef],
})
).resolves.toBe(expectedResult);
expect(sendMessageSpy).toHaveBeenCalledWith(
'demo',
expect.objectContaining({
member: 'lead',
from: 'system',
summary: 'Potential stalled tasks detected',
text: 'Task #1 looks stalled.',
taskRefs: [leadTaskRef],
source: 'system_notification',
})
);
});
});

View file

@ -461,6 +461,195 @@ function buildResolvedMember(name: string): ResolvedTeamMember {
}
describe('TeamDataService', () => {
it('rejects duplicate member names in replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await expect(
service.replaceMembers('dup-team', {
members: [
{ name: 'alice', role: 'Reviewer' },
{ name: 'alice', role: 'Developer' },
],
})
).rejects.toThrow('Member "alice" already exists');
expect(writeMembers).not.toHaveBeenCalled();
});
it('rejects invalid or reserved member names in replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await expect(
service.replaceMembers('dup-team', {
members: [{ name: 'bad/name', role: 'Reviewer' }],
})
).rejects.toThrow('Member name "bad/name" is invalid');
await expect(
service.replaceMembers('dup-team', {
members: [{ name: 'user', role: 'Reviewer' }],
})
).rejects.toThrow('Member name "user" is reserved');
expect(writeMembers).not.toHaveBeenCalled();
});
it('preserves agentId for existing members during replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
agentType: 'general-purpose',
agentId: 'alice@runtime-team',
joinedAt: 1710000000000,
},
]),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await service.replaceMembers('runtime-team', {
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
},
],
});
expect(writeMembers).toHaveBeenCalledWith(
'runtime-team',
expect.arrayContaining([
expect.objectContaining({
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
agentId: 'alice@runtime-team',
}),
])
);
});
it('does not carry over agentId from a previously removed member with the same name', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
agentType: 'general-purpose',
agentId: 'alice@old-runtime-team',
joinedAt: 1710000000000,
removedAt: 1715000000000,
},
]),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await service.replaceMembers('runtime-team', {
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
},
],
});
expect(writeMembers).toHaveBeenCalledWith(
'runtime-team',
expect.arrayContaining([
expect.objectContaining({
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
agentId: undefined,
removedAt: undefined,
}),
])
);
});
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = [];
const tasks: TeamTask[] = [

View file

@ -116,4 +116,38 @@ describe('TeamLogSourceTracker', () => {
await tracker.disableTracking('demo', 'task_log_stream');
await tracker.disableTracking('demo', 'tool_activity');
});
it('supports stall_monitor as an independent tracking consumer', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
const logsFinder = {
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
})),
} as unknown as TeamMemberLogsFinder;
const tracker = new TeamLogSourceTracker(logsFinder);
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
tracker.setEmitter(emitter);
await tracker.enableTracking('demo', 'stall_monitor');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
const taskId = '323e4567-e89b-12d3-a456-426614174999';
const signalDir = path.join(tempDir, '.board-task-log-freshness');
await mkdir(signalDir, { recursive: true });
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
});
});
await tracker.disableTracking('demo', 'stall_monitor');
});
});

View file

@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeamMessageFeedService } from '../../../../src/main/services/team/TeamMessageFeedService';
import type { InboxMessage, TeamConfig } from '../../../../src/shared/types/team';
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
return {
from: 'user',
to: 'jack',
text: 'Тут?',
timestamp: '2026-04-19T18:46:37.613Z',
read: true,
source: 'user_sent',
messageId: 'user-send-1',
...overrides,
};
}
describe('TeamMessageFeedService', () => {
const config: TeamConfig = {
name: 'Signal Ops 4',
members: [{ name: 'team-lead', role: 'Lead' }],
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-19T18:46:40.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('reuses the cached feed within the cache TTL when no dirty invalidation arrives', async () => {
let inboxMessages: InboxMessage[] = [makeMessage()];
const getInboxMessages = vi.fn(async () => inboxMessages);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const first = await service.getFeed('signal-ops-4');
expect(first.messages).toHaveLength(1);
inboxMessages = [
makeMessage({
from: 'jack',
to: 'user',
text: 'Да, я тут, на связи. Что нужно сделать/проверить?',
source: 'inbox',
timestamp: '2026-04-19T18:46:43.427Z',
}),
...inboxMessages,
];
vi.setSystemTime(new Date('2026-04-19T18:46:43.000Z'));
const second = await service.getFeed('signal-ops-4');
expect(getInboxMessages).toHaveBeenCalledTimes(1);
expect(second.messages).toHaveLength(1);
});
it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => {
let inboxMessages: InboxMessage[] = [makeMessage()];
const getInboxMessages = vi.fn(async () => inboxMessages);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
await service.getFeed('signal-ops-4');
inboxMessages = [
makeMessage({
from: 'jack',
to: 'user',
text: 'Да, я тут, на связи. Что нужно сделать/проверить?',
source: 'inbox',
timestamp: '2026-04-19T18:46:43.427Z',
}),
makeMessage(),
];
vi.setSystemTime(new Date('2026-04-19T18:46:46.500Z'));
const refreshed = await service.getFeed('signal-ops-4');
expect(getInboxMessages).toHaveBeenCalledTimes(2);
expect(
refreshed.messages.some(
(message) =>
message.from === 'jack' &&
message.to === 'user' &&
message.text.includes('Да, я тут')
)
).toBe(true);
});
});

File diff suppressed because it is too large Load diff

View file

@ -293,6 +293,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(message).toContain('team_name="forge-labs", name="alice"');
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.');
expect(message).toContain(
'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.'
);
expect(message).toContain(
'If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.'
);
});
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {

View file

@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest';
import { ActiveTeamRegistry } from '../../../../../src/main/services/team/stallMonitor/ActiveTeamRegistry';
describe('ActiveTeamRegistry', () => {
it('activates a team on lead-activity and enables stall-monitor tracking', async () => {
const tracker = {
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
};
const registry = new ActiveTeamRegistry(
{ listAliveProcessTeams: vi.fn(async () => []) },
tracker as never
);
registry.noteTeamChange({
type: 'lead-activity',
teamName: 'demo',
detail: 'active',
});
await vi.waitFor(() => {
expect(tracker.enableTracking).toHaveBeenCalledWith('demo', 'stall_monitor');
});
await expect(registry.listActiveTeams()).resolves.toEqual(['demo']);
});
it('does not re-enable tracking for repeated activation events on the same team', async () => {
const tracker = {
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
};
const registry = new ActiveTeamRegistry(
{ listAliveProcessTeams: vi.fn(async () => []) },
tracker as never
);
registry.noteTeamChange({
type: 'lead-activity',
teamName: 'demo',
detail: 'active',
});
registry.noteTeamChange({
type: 'member-spawn',
teamName: 'demo',
detail: 'alice',
});
await vi.waitFor(() => {
expect(tracker.enableTracking).toHaveBeenCalledTimes(1);
});
await expect(registry.listActiveTeams()).resolves.toEqual(['demo']);
});
it('does not cold-activate a team from task-log-change alone', async () => {
const tracker = {
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
};
const registry = new ActiveTeamRegistry(
{ listAliveProcessTeams: vi.fn(async () => []) },
tracker as never
);
registry.noteTeamChange({
type: 'task-log-change',
teamName: 'cold-team',
taskId: 'task-1',
});
expect(tracker.enableTracking).not.toHaveBeenCalled();
await expect(registry.listActiveTeams()).resolves.toEqual([]);
});
it('reconciles alive teams through TeamDataService helper and tracker consumer', async () => {
const tracker = {
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
};
const registry = new ActiveTeamRegistry(
{ listAliveProcessTeams: vi.fn(async () => ['beta']) },
tracker as never
);
registry.noteTeamChange({
type: 'member-spawn',
teamName: 'alpha',
detail: 'alice',
});
await vi.waitFor(() => {
expect(tracker.enableTracking).toHaveBeenCalledWith('alpha', 'stall_monitor');
});
tracker.enableTracking.mockClear();
await registry.reconcile();
expect(tracker.enableTracking).toHaveBeenCalledWith('beta', 'stall_monitor');
expect(tracker.disableTracking).toHaveBeenCalledWith('alpha', 'stall_monitor');
await expect(registry.listActiveTeams()).resolves.toEqual(['beta']);
});
it('does not re-enable tracking for teams that are already active during reconcile', async () => {
const tracker = {
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
};
const registry = new ActiveTeamRegistry(
{ listAliveProcessTeams: vi.fn(async () => ['demo']) },
tracker as never
);
registry.noteTeamChange({
type: 'lead-activity',
teamName: 'demo',
detail: 'active',
});
await vi.waitFor(() => {
expect(tracker.enableTracking).toHaveBeenCalledTimes(1);
});
tracker.enableTracking.mockClear();
await registry.reconcile();
expect(tracker.enableTracking).not.toHaveBeenCalled();
await expect(registry.listActiveTeams()).resolves.toEqual(['demo']);
});
});

View file

@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import { BoardTaskActivityBatchIndexer } from '../../../../../src/main/services/team/stallMonitor/BoardTaskActivityBatchIndexer';
import { BoardTaskActivityRecordBuilder } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
import type { RawTaskActivityMessage } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
import type { TeamTask } from '../../../../../src/shared/types';
describe('BoardTaskActivityBatchIndexer', () => {
it('delegates one batched build through buildForTasks', () => {
const built = new Map([['task-a', [{ id: 'r1' }]]]);
const builder = {
buildForTasks: vi.fn(() => built),
};
const indexer = new BoardTaskActivityBatchIndexer(builder as never);
const result = indexer.buildIndex({
teamName: 'demo',
tasks: [{ id: 'task-a', subject: 'A', status: 'in_progress' } as TeamTask],
messages: [{ uuid: 'm1' } as RawTaskActivityMessage],
});
expect(result).toBe(built);
expect(builder.buildForTasks).toHaveBeenCalledTimes(1);
});
it('keeps buildForTask behavior consistent with batched build', () => {
const builder = new BoardTaskActivityRecordBuilder();
const taskA: TeamTask = {
id: 'task-a',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
};
const taskB: TeamTask = {
id: 'task-b',
displayId: 'deadbeef',
subject: 'Task B',
status: 'pending',
};
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/session.jsonl',
uuid: 'msg-1',
timestamp: '2026-04-19T12:00:00.000Z',
sessionId: 'session-a',
agentName: 'alice',
isSidechain: true,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'tool-1',
task: {
ref: 'task-a',
refKind: 'canonical',
canonicalId: 'task-a',
},
targetRole: 'subject',
linkKind: 'board_action',
actorContext: {
relation: 'same_task',
},
},
{
schemaVersion: 1,
toolUseId: 'tool-2',
task: {
ref: 'task-b',
refKind: 'canonical',
canonicalId: 'task-b',
},
targetRole: 'subject',
linkKind: 'board_action',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'tool-1',
canonicalToolName: 'task_start',
},
{
schemaVersion: 1,
toolUseId: 'tool-2',
canonicalToolName: 'task_add_comment',
},
],
},
];
const recordsByTaskId = builder.buildForTasks({
teamName: 'demo',
tasks: [taskA, taskB],
messages,
});
expect(recordsByTaskId.get('task-a')).toEqual(
builder.buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA, taskB],
messages,
})
);
expect(recordsByTaskId.get('task-b')).toEqual(
builder.buildForTask({
teamName: 'demo',
targetTask: taskB,
tasks: [taskA, taskB],
messages,
})
);
});
});

View file

@ -0,0 +1,57 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dirPath) => {
await fs.rm(dirPath, { recursive: true, force: true });
})
);
});
describe('TeamTaskLogFreshnessReader', () => {
it('reads valid freshness signals and normalizes transcript basename', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
tempDirs.push(projectDir);
const signalDir = path.join(projectDir, '.board-task-log-freshness');
await fs.mkdir(signalDir, { recursive: true });
await fs.writeFile(
path.join(signalDir, `${encodeURIComponent('task-a')}.json`),
JSON.stringify({
taskId: 'task-a',
updatedAt: '2026-04-19T12:00:00.000Z',
transcriptFile: '/tmp/nested/session-a.jsonl',
}),
'utf8'
);
await fs.writeFile(
path.join(signalDir, `${encodeURIComponent('task-b')}.json`),
JSON.stringify({
taskId: 'task-b',
updatedAt: 'not-a-date',
}),
'utf8'
);
const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, [
'task-a',
'task-b',
'task-missing',
]);
expect([...signals.keys()]).toEqual(['task-a']);
expect(signals.get('task-a')).toEqual({
taskId: 'task-a',
updatedAt: '2026-04-19T12:00:00.000Z',
filePath: path.join(signalDir, `${encodeURIComponent('task-a')}.json`),
transcriptFileBasename: 'session-a.jsonl',
});
});
});

View file

@ -0,0 +1,149 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { TeamTaskStallExactRowReader } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallExactRowReader';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dirPath) => {
await fs.rm(dirPath, { recursive: true, force: true });
})
);
});
function createAssistantEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
requestId?: string;
}): Record<string, unknown> {
return {
type: 'assistant',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: 'session-a',
teamName: 'demo',
agentName: 'alice',
isSidechain: true,
...(args.requestId ? { requestId: args.requestId } : {}),
message: {
id: `${args.uuid}-msg`,
role: 'assistant',
model: 'claude-test',
type: 'message',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
content: args.content,
},
};
}
function createUserEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
sourceToolUseID?: string;
}): Record<string, unknown> {
return {
type: 'user',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: 'session-a',
teamName: 'demo',
agentName: 'alice',
isSidechain: true,
...(args.sourceToolUseID ? { sourceToolUseID: args.sourceToolUseID } : {}),
message: {
role: 'user',
content: args.content,
},
};
}
describe('TeamTaskStallExactRowReader', () => {
it('keeps strict rows with subtype and tool ids', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-exact-rows-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'session.jsonl');
await fs.writeFile(
filePath,
[
JSON.stringify({
type: 'system',
uuid: 'sys-init',
timestamp: '2026-04-19T12:00:00.000Z',
sessionId: 'session-a',
teamName: 'demo',
agentName: 'alice',
isSidechain: true,
isMeta: true,
subtype: 'turn_duration',
durationMs: 1234,
}),
JSON.stringify(
createAssistantEntry({
uuid: 'asst-1',
timestamp: '2026-04-19T12:01:00.000Z',
requestId: 'req-1',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'task_start',
input: { taskId: 'task-a' },
},
],
})
),
JSON.stringify(
createUserEntry({
uuid: 'user-1',
timestamp: '2026-04-19T12:01:01.000Z',
sourceToolUseID: 'tool-1',
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }],
})
),
JSON.stringify({
uuid: 'bad-ts',
type: 'assistant',
timestamp: 'not-a-date',
message: { role: 'assistant', content: 'bad row' },
}),
].join('\n'),
'utf8'
);
const parsed = await new TeamTaskStallExactRowReader().parseFiles([filePath]);
const rows = parsed.get(filePath) ?? [];
expect(rows).toHaveLength(3);
expect(rows.map((row) => row.messageUuid)).toEqual(['sys-init', 'asst-1', 'user-1']);
expect(rows[0]).toMatchObject({
systemSubtype: 'turn_duration',
sourceOrder: 1,
toolUseIds: [],
toolResultIds: [],
});
expect(rows[1]).toMatchObject({
requestId: 'req-1',
toolUseIds: ['tool-1'],
toolResultIds: [],
sourceOrder: 2,
});
expect(rows[2]).toMatchObject({
sourceToolUseId: 'tool-1',
toolUseIds: [],
toolResultIds: ['tool-1'],
sourceOrder: 3,
});
});
});

View file

@ -0,0 +1,51 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TeamTaskStallJournal } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallJournal';
import { setClaudeBasePathOverride } from '../../../../../src/main/utils/pathDecoder';
describe('TeamTaskStallJournal', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('requires two scans before returning an alert-ready candidate', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
setClaudeBasePathOverride(tmpDir);
await fs.mkdir(path.join(tmpDir, 'teams', 'demo'), { recursive: true });
const journal = new TeamTaskStallJournal();
const evaluation = {
status: 'alert',
taskId: 'task-a',
branch: 'work',
signal: 'turn_ended_after_touch',
epochKey: 'task-a:epoch-1',
reason: 'Potential work stall',
} as const;
const firstReady = await journal.reconcileScan({
teamName: 'demo',
evaluations: [evaluation],
activeTaskIds: ['task-a'],
now: '2026-04-19T12:10:00.000Z',
});
const secondReady = await journal.reconcileScan({
teamName: 'demo',
evaluations: [evaluation],
activeTaskIds: ['task-a'],
now: '2026-04-19T12:11:00.000Z',
});
expect(firstReady).toEqual([]);
expect(secondReady).toEqual([evaluation]);
});
});

View file

@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamTaskStallMonitor } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallMonitor';
describe('TeamTaskStallMonitor', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('runs end-to-end and notifies only after a second confirmed scan', async () => {
vi.useFakeTimers();
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'true');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'true');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');
const registry = {
start: vi.fn(),
stop: vi.fn(async () => undefined),
noteTeamChange: vi.fn(),
listActiveTeams: vi.fn(async () => ['demo']),
};
const snapshot = {
teamName: 'demo',
inProgressTasks: [{ id: 'task-a', displayId: 'abcd1234', subject: 'Task A' }],
reviewOpenTasks: [],
allTasksById: new Map([
['task-a', { id: 'task-a', displayId: 'abcd1234', subject: 'Task A' }],
]),
};
const snapshotSource = {
getSnapshot: vi.fn(async () => snapshot),
};
const policy = {
evaluateWork: vi.fn(() => ({
status: 'alert',
taskId: 'task-a',
branch: 'work',
signal: 'turn_ended_after_touch',
epochKey: 'task-a:epoch',
reason: 'Potential work stall.',
})),
evaluateReview: vi.fn(),
};
const journal = {
reconcileScan: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
status: 'alert',
taskId: 'task-a',
branch: 'work',
signal: 'turn_ended_after_touch',
epochKey: 'task-a:epoch',
reason: 'Potential work stall.',
},
]),
markAlerted: vi.fn(async () => undefined),
};
const notifier = {
notifyLead: vi.fn(async () => undefined),
};
const monitor = new TeamTaskStallMonitor(
registry as never,
snapshotSource as never,
policy as never,
journal as never,
notifier as never
);
monitor.start();
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(2_100);
expect(snapshotSource.getSnapshot).toHaveBeenCalledTimes(2);
expect(notifier.notifyLead).toHaveBeenCalledTimes(1);
expect(journal.markAlerted).toHaveBeenCalledWith(
'demo',
'task-a:epoch',
expect.any(String)
);
});
});

View file

@ -0,0 +1,460 @@
import { describe, expect, it } from 'vitest';
import { TeamTaskStallPolicy } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallPolicy';
import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes';
import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
import type { ParsedMessage } from '../../../../../src/main/types';
import type { TeamTask } from '../../../../../src/shared/types';
function createParsedMessage(overrides: Partial<ParsedMessage>): ParsedMessage {
return {
uuid: 'msg-default',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-19T12:00:00.000Z'),
content: '',
isSidechain: true,
isMeta: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
function createExactRow(overrides: Partial<TeamTaskStallExactRow> = {}): TeamTaskStallExactRow {
return {
filePath: '/tmp/session.jsonl',
sourceOrder: 1,
messageUuid: 'msg-touch',
timestamp: '2026-04-19T12:00:00.000Z',
parsedMessage: createParsedMessage({ uuid: 'msg-touch' }),
toolUseIds: [],
toolResultIds: [],
...overrides,
};
}
function createRecord(overrides: Partial<BoardTaskActivityRecord> = {}): BoardTaskActivityRecord {
return {
id: 'rec-1',
timestamp: '2026-04-19T12:00:00.000Z',
task: {
locator: {
ref: 'task-a',
refKind: 'canonical',
canonicalId: 'task-a',
},
resolution: 'resolved',
taskRef: {
taskId: 'task-a',
displayId: 'abcd1234',
teamName: 'demo',
},
},
linkKind: 'board_action',
targetRole: 'subject',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-a',
isSidechain: true,
},
actorContext: {
relation: 'same_task',
},
action: {
canonicalToolName: 'task_start',
category: 'status',
toolUseId: 'tool-1',
},
source: {
messageUuid: 'msg-touch',
filePath: '/tmp/session.jsonl',
toolUseId: 'tool-1',
sourceOrder: 1,
},
...overrides,
};
}
function createSnapshot(overrides: Partial<TeamTaskStallSnapshot>): TeamTaskStallSnapshot {
return {
teamName: 'demo',
scannedAt: '2026-04-19T12:30:00.000Z',
projectDir: '/tmp/project',
projectId: 'project-id',
leadName: 'team-lead',
transcriptFiles: ['/tmp/session.jsonl'],
activityReadsEnabled: true,
exactReadsEnabled: true,
activeTasks: [],
deletedTasks: [],
allTasksById: new Map(),
inProgressTasks: [],
reviewOpenTasks: [],
resolvedReviewersByTaskId: new Map(),
recordsByTaskId: new Map(),
freshnessByTaskId: new Map(),
exactRowsByFilePath: new Map(),
...overrides,
};
}
describe('TeamTaskStallPolicy', () => {
const policy = new TeamTaskStallPolicy();
it('alerts for work stall after turn ended and threshold elapsed', () => {
const task: TeamTask = {
id: 'task-a',
displayId: 'abcd1234',
subject: 'Task A',
owner: 'alice',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }],
};
const record = createRecord();
const snapshot = createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-a', task]]),
inProgressTasks: [task],
recordsByTaskId: new Map([['task-a', [record]]]),
exactRowsByFilePath: new Map([
[
'/tmp/session.jsonl',
[
createExactRow({
messageUuid: 'msg-touch',
toolUseIds: ['tool-1'],
}),
createExactRow({
sourceOrder: 2,
messageUuid: 'msg-turn-end',
systemSubtype: 'turn_duration',
parsedMessage: createParsedMessage({
uuid: 'msg-turn-end',
type: 'system',
}),
}),
],
],
]),
});
const evaluation = policy.evaluateWork({
now: new Date('2026-04-19T12:30:00.000Z'),
task,
snapshot,
});
expect(evaluation).toMatchObject({
status: 'alert',
taskId: 'task-a',
branch: 'work',
signal: 'turn_ended_after_touch',
});
});
it('fails closed on review branch when review has not started yet', () => {
const task: TeamTask = {
id: 'task-b',
displayId: 'deadbeef',
subject: 'Task B',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-requested',
type: 'review_requested',
timestamp: '2026-04-19T12:00:00.000Z',
from: 'none',
to: 'review',
},
],
};
const evaluation = policy.evaluateReview({
now: new Date('2026-04-19T12:30:00.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-b', task]]),
reviewOpenTasks: [task],
}),
});
expect(evaluation).toMatchObject({
status: 'skip',
taskId: 'task-b',
skipReason: 'no_open_review_window',
});
});
it('fails closed on review branch when reviewer cannot be resolved after review has started', () => {
const task: TeamTask = {
id: 'task-b2',
displayId: 'deadbe12',
subject: 'Task B2',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-started',
type: 'review_started',
timestamp: '2026-04-19T12:01:00.000Z',
from: 'review',
to: 'review',
},
],
};
const evaluation = policy.evaluateReview({
now: new Date('2026-04-19T12:30:00.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-b2', task]]),
reviewOpenTasks: [task],
}),
});
expect(evaluation).toMatchObject({
status: 'skip',
taskId: 'task-b2',
skipReason: 'reviewer_unresolved',
});
});
it('does not treat review_requested alone as started-review evidence', () => {
const task: TeamTask = {
id: 'task-review-requested-only',
displayId: 'feedbeef',
subject: 'Task review requested only',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-requested',
type: 'review_requested',
timestamp: '2026-04-19T12:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'bob',
},
],
};
const evaluation = policy.evaluateReview({
now: new Date('2026-04-19T12:30:00.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-review-requested-only', task]]),
reviewOpenTasks: [task],
resolvedReviewersByTaskId: new Map([
[
'task-review-requested-only',
{ reviewer: 'bob', source: 'history_review_requested_reviewer' },
],
]),
}),
});
expect(evaluation).toMatchObject({
status: 'skip',
taskId: 'task-review-requested-only',
skipReason: 'no_open_review_window',
});
});
it('alerts for started-review stall after explicit review_start evidence', () => {
const task: TeamTask = {
id: 'task-c',
displayId: 'c0ffee12',
subject: 'Task C',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-requested',
type: 'review_requested',
timestamp: '2026-04-19T12:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'bob',
},
{
id: 'evt-review-started',
type: 'review_started',
timestamp: '2026-04-19T12:01:00.000Z',
from: 'review',
to: 'review',
actor: 'bob',
},
],
};
const record = createRecord({
id: 'rec-review',
timestamp: '2026-04-19T12:01:00.000Z',
actor: {
memberName: 'bob',
role: 'member',
sessionId: 'session-b',
isSidechain: true,
},
actorContext: {
relation: 'same_task',
activePhase: 'review',
},
action: {
canonicalToolName: 'review_start',
category: 'review',
toolUseId: 'tool-review',
},
source: {
messageUuid: 'msg-review-touch',
filePath: '/tmp/review.jsonl',
toolUseId: 'tool-review',
sourceOrder: 1,
},
});
const evaluation = policy.evaluateReview({
now: new Date('2026-04-19T12:20:30.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-c', task]]),
reviewOpenTasks: [task],
resolvedReviewersByTaskId: new Map([
['task-c', { reviewer: 'bob', source: 'history_review_started_actor' }],
]),
recordsByTaskId: new Map([['task-c', [record]]]),
exactRowsByFilePath: new Map([
[
'/tmp/review.jsonl',
[
createExactRow({
filePath: '/tmp/review.jsonl',
messageUuid: 'msg-review-touch',
toolUseIds: ['tool-review'],
}),
createExactRow({
filePath: '/tmp/review.jsonl',
sourceOrder: 2,
messageUuid: 'msg-review-turn-end',
systemSubtype: 'turn_duration',
parsedMessage: createParsedMessage({
uuid: 'msg-review-turn-end',
type: 'system',
}),
}),
],
],
]),
}),
});
expect(evaluation).toMatchObject({
status: 'alert',
taskId: 'task-c',
branch: 'review',
signal: 'turn_ended_after_touch',
});
});
it('alerts for started-review stall when review_started actor is missing but same-task reviewer touch exists after the review start', () => {
const task: TeamTask = {
id: 'task-d',
displayId: 'ddaa5511',
subject: 'Task D',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-requested',
type: 'review_requested',
timestamp: '2026-04-19T12:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'bob',
},
{
id: 'evt-review-started',
type: 'review_started',
timestamp: '2026-04-19T12:01:00.000Z',
from: 'review',
to: 'review',
},
],
};
const record = createRecord({
id: 'rec-review-comment',
timestamp: '2026-04-19T12:02:00.000Z',
actor: {
memberName: 'bob',
role: 'member',
sessionId: 'session-b',
isSidechain: true,
},
actorContext: {
relation: 'same_task',
activePhase: 'review',
},
action: {
canonicalToolName: 'task_add_comment',
category: 'comment',
toolUseId: 'tool-review-comment',
},
source: {
messageUuid: 'msg-review-comment',
filePath: '/tmp/review-missing-actor.jsonl',
toolUseId: 'tool-review-comment',
sourceOrder: 1,
},
});
const evaluation = policy.evaluateReview({
now: new Date('2026-04-19T12:20:30.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([['task-d', task]]),
reviewOpenTasks: [task],
resolvedReviewersByTaskId: new Map([
['task-d', { reviewer: 'bob', source: 'history_review_requested_reviewer' }],
]),
recordsByTaskId: new Map([['task-d', [record]]]),
exactRowsByFilePath: new Map([
[
'/tmp/review-missing-actor.jsonl',
[
createExactRow({
filePath: '/tmp/review-missing-actor.jsonl',
messageUuid: 'msg-review-comment',
toolUseIds: ['tool-review-comment'],
}),
createExactRow({
filePath: '/tmp/review-missing-actor.jsonl',
sourceOrder: 2,
messageUuid: 'msg-review-turn-end',
systemSubtype: 'turn_duration',
parsedMessage: createParsedMessage({
uuid: 'msg-review-turn-end',
type: 'system',
}),
}),
],
],
]),
}),
});
expect(evaluation).toMatchObject({
status: 'alert',
taskId: 'task-d',
branch: 'review',
signal: 'turn_ended_after_touch',
});
});
});

View file

@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import { TeamTaskStallSnapshotSource } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource';
describe('TeamTaskStallSnapshotSource', () => {
it('returns null when transcript context is unavailable', async () => {
const source = new TeamTaskStallSnapshotSource(
{ getContext: vi.fn(async () => null) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
await expect(source.getSnapshot('demo')).resolves.toBeNull();
});
it('builds one batched snapshot and narrows exact/freshness reads to work and started-review candidates', async () => {
const activeTasks = [
{ id: 'task-a', subject: 'A', status: 'in_progress' },
{
id: 'task-b',
subject: 'B',
status: 'completed',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review-requested',
type: 'review_requested',
timestamp: '2026-04-19T12:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'alice',
},
],
},
];
const deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }];
const transcriptContext = {
projectDir: '/tmp/project',
projectId: 'project-id',
config: {
members: [{ name: 'team-lead', role: 'team lead' }],
} as never,
sessionIds: ['session-a'],
transcriptFiles: ['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl'],
};
const rawMessages = [{ uuid: 'm1' }];
const recordsByTaskId = new Map([
[
'task-a',
[
{
id: 'r1',
source: {
filePath: '/tmp/project/session-b.jsonl',
},
},
],
],
[
'task-b',
[
{
id: 'r2',
source: {
filePath: '/tmp/project/session-a.jsonl',
},
},
],
],
]);
const freshnessByTaskId = new Map([
['task-a', { taskId: 'task-a', updatedAt: '2026-04-19T12:00:00.000Z', filePath: '/tmp/fresh.json' }],
]);
const exactRowsByFilePath = new Map([['/tmp/project/session-b.jsonl', []]]);
const locator = {
getContext: vi.fn(async () => transcriptContext),
};
const taskReader = {
getTasks: vi.fn(async () => activeTasks),
getDeletedTasks: vi.fn(async () => deletedTasks),
};
const kanbanManager = {
getState: vi.fn(async () => ({
teamName: 'demo',
reviewers: ['alice'],
tasks: {
'task-b': {
column: 'review',
movedAt: '2026-04-19T12:00:00.000Z',
reviewer: 'alice',
},
},
})),
};
const transcriptReader = {
readFiles: vi.fn(async () => rawMessages),
};
const batchIndexer = {
buildIndex: vi.fn(() => recordsByTaskId),
};
const freshnessReader = {
readSignals: vi.fn(async () => freshnessByTaskId),
};
const exactRowReader = {
parseFiles: vi.fn(async () => exactRowsByFilePath),
};
const source = new TeamTaskStallSnapshotSource(
locator as never,
taskReader as never,
kanbanManager as never,
transcriptReader as never,
batchIndexer as never,
freshnessReader as never,
exactRowReader as never
);
const snapshot = await source.getSnapshot('demo');
expect(snapshot).not.toBeNull();
expect(batchIndexer.buildIndex).toHaveBeenCalledWith({
teamName: 'demo',
tasks: [...activeTasks, ...deletedTasks],
messages: rawMessages,
});
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
expect(snapshot?.leadName).toBe('team-lead');
expect(snapshot?.resolvedReviewersByTaskId.get('task-b')).toEqual({
reviewer: 'alice',
source: 'kanban_state',
});
expect(snapshot?.recordsByTaskId).toBe(recordsByTaskId);
});
});

View file

@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getTeamTaskStallActivationGraceMs,
getTeamTaskStallScanIntervalMs,
getTeamTaskStallStartupGraceMs,
isTeamTaskStallAlertsEnabled,
isTeamTaskStallMonitorEnabled,
} from '../../../../../src/main/services/team/stallMonitor/featureGates';
afterEach(() => {
vi.unstubAllEnvs();
});
describe('stallMonitor feature gates', () => {
it('defaults both monitor and alerts to disabled', () => {
expect(isTeamTaskStallMonitorEnabled()).toBe(false);
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
expect(getTeamTaskStallScanIntervalMs()).toBe(60_000);
expect(getTeamTaskStallStartupGraceMs()).toBe(180_000);
expect(getTeamTaskStallActivationGraceMs()).toBe(120_000);
});
it('parses truthy and falsy environment values', () => {
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'true');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'off');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1500');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '2000');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '3000');
expect(isTeamTaskStallMonitorEnabled()).toBe(true);
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
expect(getTeamTaskStallScanIntervalMs()).toBe(1500);
expect(getTeamTaskStallStartupGraceMs()).toBe(2000);
expect(getTeamTaskStallActivationGraceMs()).toBe(3000);
});
});

View file

@ -13,8 +13,13 @@ vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
ENTRY_REVEAL_ANIMATION_MS: 220,
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
AnimatedHeightReveal: ({
children,
containerRef,
}: {
children: React.ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
}) => React.createElement('div', { ref: containerRef }, children),
}));
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
@ -284,4 +289,304 @@ describe('ActivityTimeline session separators', () => {
root.unmount();
});
});
it('renders each separator distinctly when the same session transition repeats', async () => {
const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'thought-b-2',
text: 'b second',
leadSessionId: 'lead-session-b',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-a-2',
text: 'a second',
leadSessionId: 'lead-session-a',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-b-1',
text: 'b first',
leadSessionId: 'lead-session-b',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-a-1',
text: 'a first',
leadSessionId: 'lead-session-a',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
// Three transitions: b→a, a→b, b→a. All three separators must render.
const matches = container.textContent?.match(/New session/g) ?? [];
expect(matches.length).toBe(3);
// React warns via `console.error` when duplicate keys are detected.
const duplicateKeyWarnings = warnSpy.mock.calls.filter((call) =>
String(call[0]).includes('unique "key"')
);
expect(duplicateKeyWarnings).toHaveLength(0);
warnSpy.mockRestore();
await act(async () => {
root.unmount();
});
});
});
describe('ActivityTimeline viewport observerRoot', () => {
let container: HTMLDivElement;
let capturedRoots: Array<Element | Document | null>;
let originalIntersectionObserver:
| typeof globalThis.IntersectionObserver
| undefined;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
container = document.createElement('div');
document.body.appendChild(container);
capturedRoots = [];
originalIntersectionObserver = globalThis.IntersectionObserver;
class FakeIntersectionObserver {
public readonly root: Element | Document | null;
public readonly rootMargin: string;
public readonly thresholds: ReadonlyArray<number>;
constructor(
_callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
) {
this.root = options?.root ?? null;
this.rootMargin = options?.rootMargin ?? '0px';
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: typeof options?.threshold === 'number'
? [options.threshold]
: [0];
capturedRoots.push(this.root);
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
});
afterEach(() => {
if (originalIntersectionObserver) {
globalThis.IntersectionObserver = originalIntersectionObserver;
}
container.remove();
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('creates IntersectionObservers with root=null when no viewport is passed', async () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'msg-1',
text: 'hello',
from: 'alice',
source: 'inbox',
}),
];
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages,
teamName: 'demo-team',
onMessageVisible: () => {},
})
);
});
expect(capturedRoots.length).toBeGreaterThan(0);
expect(capturedRoots.every((r) => r === null)).toBe(true);
await act(async () => {
root.unmount();
});
});
it('creates IntersectionObservers with the provided root when viewport.observerRoot is set', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'msg-1',
text: 'hello',
from: 'alice',
source: 'inbox',
}),
];
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages,
teamName: 'demo-team',
onMessageVisible: () => {},
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: false,
},
})
);
});
expect(capturedRoots.length).toBeGreaterThan(0);
expect(capturedRoots.every((r) => r === scrollHost)).toBe(true);
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
});
describe('ActivityTimeline virtualization threshold', () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
const buildMany = (count: number): InboxMessage[] =>
Array.from({ length: count }, (_, i) =>
makeMessage({
messageId: `msg-${i}`,
text: `message ${i}`,
from: 'alice',
source: 'inbox',
leadSessionId: `member-session-${i}`,
})
);
it('does not enter the virtualized render path when the row count is below the threshold', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(10),
teamName: 'demo-team',
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: true,
},
})
);
});
// Virtualized path wraps items in an absolute-position container; the
// direct path does not. Assert the wrapper is absent.
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]');
expect(absoluteWrapper).toBeNull();
// Sanity check: direct render still emits at least one activity item.
expect(container.textContent).toContain('message 0');
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
it('falls back to the direct render path when no viewport is provided', async () => {
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(80),
teamName: 'demo-team',
})
);
});
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]');
expect(absoluteWrapper).toBeNull();
expect(container.textContent).toContain('message 0');
await act(async () => {
root.unmount();
});
});
it('enters the virtualized render path when row count crosses the threshold', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(80),
teamName: 'demo-team',
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: true,
},
})
);
});
// Default pagination caps visible rows at 30, which stays below the
// threshold, so the direct render path is in effect here. Click "show
// all" to expose every message — that pushes row count past the gate.
const showAllButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent?.toLowerCase().includes('show all')
);
expect(showAllButton).toBeDefined();
await act(async () => {
showAllButton?.click();
});
// Virtualized path: sized container div with `position: relative`
// directly inside the timeline root. jsdom serialises style attributes
// with spaces after the colon, so match case-insensitively.
const html = container.innerHTML;
expect(html.toLowerCase()).toMatch(/position:\s*relative/);
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
});

View file

@ -0,0 +1,135 @@
import React from 'react';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage } from '@shared/types';
const useVirtualizerMock = vi.fn(
(options: Record<string, unknown>) =>
({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => undefined,
options,
}) as const
);
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: (options: Record<string, unknown>) => useVirtualizerMock(options),
}));
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
ActivityItem: ({ message }: { message: InboxMessage }) =>
React.createElement('div', { 'data-testid': 'activity-item' }, message.text),
isNoiseMessage: () => false,
}));
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
ENTRY_REVEAL_ANIMATION_MS: 220,
AnimatedHeightReveal: ({
children,
containerRef,
}: {
children: React.ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
}) => React.createElement('div', { ref: containerRef }, children),
}));
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
useNewItemKeys: () => new Set<string>(),
}));
import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline';
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
return {
from: 'alice',
text: 'message',
timestamp: '2026-04-20T10:00:00.000Z',
read: true,
source: 'inbox',
messageId: 'message-id',
leadSessionId: 'lead-session-1',
...overrides,
};
}
describe('ActivityTimeline virtualization config', () => {
let container: HTMLDivElement;
let originalResizeObserver: typeof globalThis.ResizeObserver | undefined;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useVirtualizerMock.mockClear();
container = document.createElement('div');
document.body.appendChild(container);
originalResizeObserver = globalThis.ResizeObserver;
class FakeResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
});
afterEach(() => {
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver;
}
container.remove();
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('passes the direct-path row gap into useVirtualizer when virtualization activates', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
const messages = Array.from({ length: 80 }, (_, i) =>
makeMessage({
messageId: `msg-${i}`,
text: `message ${i}`,
timestamp: new Date(Date.UTC(2026, 3, 20, 10, 0, i)).toISOString(),
leadSessionId: `member-session-${i}`,
})
);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages,
teamName: 'demo-team',
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: true,
},
})
);
});
const showAllButton = [...container.querySelectorAll('button')].find((button) =>
button.textContent?.toLowerCase().includes('show all')
);
expect(showAllButton).toBeDefined();
await act(async () => {
showAllButton?.click();
});
const lastCall = useVirtualizerMock.mock.calls.at(-1)?.[0] as
| { count?: number; gap?: number }
| undefined;
expect(lastCall?.count).toBeGreaterThanOrEqual(60);
expect(lastCall?.gap).toBe(4);
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,430 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
const openDashboard = vi.fn();
const openTeamTab = vi.fn();
const fetchCliStatus = vi.fn();
const createSchedule = vi.fn();
const updateSchedule = vi.fn();
const storeState = {
appConfig: { general: { multimodelEnabled: true } },
cliStatus: { providers: [] },
cliStatusLoading: false,
fetchCliStatus,
createSchedule,
updateSchedule,
repositoryGroups: [],
selectedTeamName: 'team-alpha',
launchParamsByTeam: {},
teamByName: {},
openDashboard,
openTeamTab,
};
vi.mock('@renderer/api', () => ({
api: {
getProjects: vi.fn(async () => [
{
id: 'project-1',
path: '/tmp/project',
name: 'project',
sessions: [],
totalSessions: 0,
createdAt: 1,
},
]),
teams: {
getSavedRequest: vi.fn(async () => null),
replaceMembers: vi.fn(async () => {}),
prepareProvisioning: vi.fn(async () => ({})),
},
},
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
isTeamProvisioningActive: () => false,
selectResolvedMembersForTeamName: () => [],
}));
vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
buildMemberDraftColorMap: () => new Map<string, string>(),
buildMemberDraftSuggestions: () => [],
buildMembersFromDrafts: (
drafts: Array<{
name: string;
roleSelection?: string;
customRole?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}>
) =>
drafts.map((draft) => ({
name: draft.name,
role: draft.customRole || undefined,
workflow: draft.workflow,
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined,
model: draft.model,
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
})),
clearMemberModelOverrides: (member: unknown) => member,
createMemberDraftsFromInputs: (
members: Array<{
name: string;
role?: string;
workflow?: string;
providerId?: string;
model?: string;
effort?: string;
}>
) =>
members.map((member, index) => ({
id: `draft-${index}`,
name: member.name,
originalName: member.name,
roleSelection: '',
customRole: member.role ?? '',
workflow: member.workflow ?? '',
providerId: member.providerId,
model: member.model ?? '',
effort: member.effort,
})),
filterEditableMemberInputs: (members: unknown) => members,
normalizeMemberDraftForProviderMode: (member: unknown) => member,
normalizeProviderForMode: (providerId: unknown) => providerId,
validateMemberNameInline: () => null,
}));
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
TeamRosterEditorSection: () => React.createElement('div', null, 'team-roster-editor'),
}));
vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({
SkipPermissionsCheckbox: () => React.createElement('div', null, 'skip-permissions'),
}));
vi.mock('@renderer/components/team/dialogs/AdvancedCliSection', () => ({
AdvancedCliSection: () => React.createElement('div', null, 'advanced-cli'),
}));
vi.mock('@renderer/components/team/dialogs/OptionalSettingsSection', () => ({
OptionalSettingsSection: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/dialogs/ProjectPathSelector', () => ({
ProjectPathSelector: ({ selectedProjectPath }: { selectedProjectPath: string }) =>
React.createElement('div', { 'data-testid': 'project-path' }, selectedProjectPath),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type,
disabled,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
className?: string;
}) =>
React.createElement('button', { type: type ?? 'button', onClick, disabled, className }, children),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
id,
}: {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
id?: string;
}) =>
React.createElement('input', {
id,
type: 'checkbox',
checked,
onChange: (event: Event) =>
onCheckedChange?.((event.target as HTMLInputElement).checked),
}),
}));
vi.mock('@renderer/components/ui/combobox', () => ({
Combobox: () => React.createElement('div', null, 'combobox'),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? React.createElement('div', null, children) : null),
DialogContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogTitle: ({ children }: { children: React.ReactNode }) =>
React.createElement('h2', null, children),
DialogDescription: ({ children }: { children: React.ReactNode }) =>
React.createElement('p', null, children),
DialogFooter: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: Record<string, unknown>) => React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({
children,
htmlFor,
className,
}: {
children: React.ReactNode;
htmlFor?: string;
className?: string;
}) => React.createElement('label', { htmlFor, className }, children),
}));
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
MentionableTextarea: ({
value,
onValueChange,
id,
}: {
value: string;
onValueChange: (value: string) => void;
id?: string;
}) =>
React.createElement('textarea', {
id,
value,
onChange: (event: Event) => onValueChange((event.target as HTMLTextAreaElement).value),
}),
}));
vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({
useChipDraftPersistence: () => ({
chips: [],
removeChip: vi.fn(),
addChip: vi.fn(),
clearChipDraft: vi.fn(),
}),
}));
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
useDraftPersistence: () => ({
value: '',
setValue: vi.fn(),
isSaved: false,
}),
}));
vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({
useFileListCacheWarmer: () => undefined,
}));
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
useTaskSuggestions: () => ({ suggestions: [] }),
}));
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
useTeamSuggestions: () => ({ suggestions: [] }),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/utils/geminiUiFreeze', () => ({
isGeminiUiFrozen: () => false,
normalizeCreateLaunchProviderForUi: (providerId: unknown) => providerId,
}));
vi.mock('@renderer/utils/teamModelAvailability', () => ({
getTeamModelSelectionError: () => null,
normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model,
}));
vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
buildProviderPrepareModelCacheKey: () => 'prepare-cache-key',
}));
vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({
buildReusableProviderPrepareModelResults: () => ({}),
getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }),
runProviderPrepareDiagnostics: vi.fn(async () => ({
status: 'ready',
warnings: [],
details: [],
modelResultsById: {},
})),
}));
vi.mock('@renderer/components/team/dialogs/provisioningModelIssues', () => ({
getProvisioningModelIssue: () => null,
}));
vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () => ({
ProvisioningProviderStatusList: () => React.createElement('div', null, 'provider-status-list'),
failIncompleteProviderChecks: (checks: unknown) => checks,
getPrimaryProvisioningFailureDetail: () => null,
getProvisioningFailureHint: () => 'hint',
getProvisioningProviderBackendSummary: () => null,
shouldHideProvisioningProviderStatusList: () => false,
updateProviderCheck: (checks: unknown) => checks,
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
computeEffectiveTeamModel: (model: string) => model || undefined,
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model, effort].filter(Boolean).join(' '),
}));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
}));
import { api } from '@renderer/api';
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe('LaunchTeamDialog', () => {
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
vi.clearAllMocks();
});
it('renders relaunch-specific title, warning and submit label', 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(LaunchTeamDialog, {
mode: 'relaunch',
open: true,
teamName: 'team-alpha',
members: [{ name: 'alice', role: 'Reviewer' }] as any,
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onRelaunch: vi.fn(async () => {}),
})
);
await flush();
});
expect(host.textContent).toContain('Relaunch Team');
expect(host.textContent).toContain('Relaunch will restart the current team run');
expect(
Array.from(host.querySelectorAll('button')).some(
(button) => button.textContent === 'Relaunch team'
)
).toBe(true);
await act(async () => {
root.unmount();
await flush();
});
});
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const onRelaunch = vi.fn(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'relaunch',
open: true,
teamName: 'team-alpha',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
},
] as any,
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onRelaunch,
})
);
await flush();
});
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Relaunch team'
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flush();
});
expect(onRelaunch).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.teams.replaceMembers)).not.toHaveBeenCalled();
const [request, members] = onRelaunch.mock.calls[0] as unknown as [
{ teamName: string; cwd: string; providerId?: string; model?: string },
Array<{ name: string; providerId?: string; model?: string }>
];
expect(request.teamName).toBe('team-alpha');
expect(request.cwd).toBe('/tmp/project');
expect(request.providerId).toBe('anthropic');
expect(request.model).toBe('opus');
expect(members).toEqual([
{
name: 'alice',
role: 'Reviewer',
workflow: '',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
},
]);
await act(async () => {
root.unmount();
await flush();
});
});
});

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
buildEditTeamSourceSnapshot,
getLiveRosterIdentityChanges,
getMembersRequiringRuntimeRestart,
} from '@renderer/components/team/dialogs/editTeamRuntimeChanges';
describe('getMembersRequiringRuntimeRestart', () => {
it('returns existing teammates whose role, workflow, provider, model, or effort changed', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
} as any,
{
name: 'bob',
role: 'Developer',
workflow: 'Ship features',
providerId: 'anthropic',
model: '',
effort: 'low',
} as any,
],
nextMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
},
{
name: 'bob',
role: 'Developer',
workflow: 'Ship safer features',
providerId: 'anthropic',
model: '',
effort: 'high',
},
],
});
expect(result).toEqual(['alice', 'bob']);
});
it('ignores newly added or renamed teammates for restart targeting', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
} as any,
],
nextMembers: [
{
name: 'alice-2',
role: 'Reviewer',
workflow: 'Review PRs',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
},
{
name: 'bob',
role: 'Developer',
workflow: 'Ship features',
providerId: 'anthropic',
model: '',
effort: undefined,
},
],
});
expect(result).toEqual([]);
});
it('treats empty values as unchanged normalized runtime settings', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: undefined,
workflow: undefined,
providerId: undefined,
model: undefined,
effort: undefined,
} as any,
],
nextMembers: [
{
name: 'alice',
role: '',
workflow: '',
providerId: undefined,
model: '',
effort: undefined,
},
],
});
expect(result).toEqual([]);
});
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
const result = getLiveRosterIdentityChanges({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
} as any,
{
name: 'bob',
role: 'Developer',
} as any,
],
nextDrafts: [
{
id: 'draft-alice',
name: 'alice-renamed',
originalName: 'alice',
roleSelection: '',
customRole: '',
},
] as any,
});
expect(result).toEqual({
renamed: ['alice'],
removed: ['bob'],
});
});
it('ignores live status-only member refreshes in the edit source snapshot', () => {
const base = buildEditTeamSourceSnapshot({
name: 'Team A',
description: 'desc',
color: 'blue',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
status: 'online',
branch: 'main',
} as any,
],
});
const refreshed = buildEditTeamSourceSnapshot({
name: 'Team A',
description: 'desc',
color: 'blue',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
status: 'offline',
branch: 'feature/x',
} as any,
],
});
expect(refreshed).toBe(base);
});
});

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { buildProjectPathOptions } from '@renderer/components/team/dialogs/projectPathOptions';
import type { Project } from '@shared/types';
function createProject(overrides: Partial<Project>): Project {
return {
id: 'project-id',
name: 'project',
path: '/Users/test/project',
sessions: [],
totalSessions: 0,
createdAt: 1,
...overrides,
};
}
describe('buildProjectPathOptions', () => {
it('removes duplicate projects that point to the same path', () => {
const options = buildProjectPathOptions([
createProject({
id: 'project-1',
name: 'lintai',
path: '/Users/belief/dev/projects/lintai',
}),
createProject({
id: 'project-2',
name: 'lintai duplicate',
path: '/Users/belief/dev/projects/lintai',
}),
]);
expect(options).toEqual([
{
value: '/Users/belief/dev/projects/lintai',
label: 'lintai',
description: '/Users/belief/dev/projects/lintai',
},
]);
});
it('prefers the currently selected variant when duplicate paths normalize equally', () => {
const options = buildProjectPathOptions(
[
createProject({
id: 'project-1',
name: 'LintAI',
path: '/Users/Belief/dev/projects/lintai',
}),
createProject({
id: 'project-2',
name: 'lintai',
path: '/Users/belief/dev/projects/lintai/',
}),
],
'/Users/belief/dev/projects/lintai/'
);
expect(options).toEqual([
{
value: '/Users/belief/dev/projects/lintai/',
label: 'lintai',
description: '/Users/belief/dev/projects/lintai/',
},
]);
});
});

Some files were not shown because too many files have changed in this diff Show more