Merge remote-tracking branch 'origin/dev' into spike/codex-native-runtime-plan
# Conflicts: # docs/research/codex-native-runtime-integration-decision.md
|
|
@ -3,7 +3,9 @@
|
||||||
reviews:
|
reviews:
|
||||||
auto_review:
|
auto_review:
|
||||||
enabled: true
|
enabled: true
|
||||||
drafts: false
|
drafts: true
|
||||||
|
base_branches:
|
||||||
|
- '.*'
|
||||||
auto_title_instructions: |
|
auto_title_instructions: |
|
||||||
Follow Conventional Commits format: '<type>: <description>'
|
Follow Conventional Commits format: '<type>: <description>'
|
||||||
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@
|
||||||
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
|
resolveMemberAvatarUrl,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
||||||
|
|
@ -143,6 +145,7 @@ export class TeamGraphAdapter {
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
||||||
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
||||||
|
const avatarMap = buildMemberAvatarMap(teamData.members);
|
||||||
const provisioningPresentation = buildTeamProvisioningPresentation({
|
const provisioningPresentation = buildTeamProvisioningPresentation({
|
||||||
progress: provisioningProgress,
|
progress: provisioningProgress,
|
||||||
members: teamData.members,
|
members: teamData.members,
|
||||||
|
|
@ -158,6 +161,7 @@ export class TeamGraphAdapter {
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
leadName,
|
leadName,
|
||||||
|
avatarMap,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
|
|
@ -173,6 +177,7 @@ export class TeamGraphAdapter {
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
memberNodeIdByAlias,
|
memberNodeIdByAlias,
|
||||||
|
avatarMap,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
@ -369,6 +374,7 @@ export class TeamGraphAdapter {
|
||||||
data: TeamGraphData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
avatarMap: ReadonlyMap<string, string>,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
leadActivity?: LeadActivityState,
|
leadActivity?: LeadActivityState,
|
||||||
leadContext?: LeadContextUsage,
|
leadContext?: LeadContextUsage,
|
||||||
|
|
@ -428,7 +434,9 @@ export class TeamGraphAdapter {
|
||||||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||||
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : 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,
|
pendingApproval,
|
||||||
activeTool: activeTool
|
activeTool: activeTool
|
||||||
? {
|
? {
|
||||||
|
|
@ -465,6 +473,7 @@ export class TeamGraphAdapter {
|
||||||
data: TeamGraphData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||||
|
avatarMap: ReadonlyMap<string, string>,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
|
|
@ -520,7 +529,7 @@ export class TeamGraphAdapter {
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||||
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
||||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: member.currentTaskId
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,15 @@
|
||||||
* composes project-specific UI, selectors, and presentation helpers.
|
* composes project-specific UI, selectors, and presentation helpers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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 { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
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.kind === 'member' || node.domainRef.kind === 'lead'
|
||||||
? node.domainRef.teamName
|
? node.domainRef.teamName
|
||||||
: '';
|
: '';
|
||||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
|
||||||
const {
|
const {
|
||||||
teamData,
|
teamData,
|
||||||
teamMembers,
|
teamMembers,
|
||||||
|
|
@ -301,6 +306,8 @@ const MemberPopoverContent = ({
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
} = useGraphMemberPopoverContext(teamName, memberName);
|
} = 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 member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||||
const provisioningPresentation =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ import {
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
|
ActiveTeamRegistry,
|
||||||
BoardTaskActivityDetailService,
|
BoardTaskActivityDetailService,
|
||||||
BoardTaskActivityRecordSource,
|
BoardTaskActivityRecordSource,
|
||||||
BoardTaskActivityService,
|
BoardTaskActivityService,
|
||||||
|
|
@ -130,6 +131,11 @@ import {
|
||||||
TaskBoundaryParser,
|
TaskBoundaryParser,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
TeamLogSourceTracker,
|
TeamLogSourceTracker,
|
||||||
|
TeamTaskStallJournal,
|
||||||
|
TeamTaskStallMonitor,
|
||||||
|
TeamTaskStallNotifier,
|
||||||
|
TeamTaskStallPolicy,
|
||||||
|
TeamTaskStallSnapshotSource,
|
||||||
TeammateToolTracker,
|
TeammateToolTracker,
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
|
|
@ -415,6 +421,7 @@ let cliInstallerService: CliInstallerService;
|
||||||
let ptyTerminalService: PtyTerminalService;
|
let ptyTerminalService: PtyTerminalService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
let schedulerService: SchedulerService;
|
let schedulerService: SchedulerService;
|
||||||
|
let teamTaskStallMonitor: TeamTaskStallMonitor | null = null;
|
||||||
let skillsWatcherService: SkillsWatcherService | null = null;
|
let skillsWatcherService: SkillsWatcherService | null = null;
|
||||||
let teamBackupService: TeamBackupService | null = null;
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
let branchStatusService: BranchStatusService | null = null;
|
let branchStatusService: BranchStatusService | null = null;
|
||||||
|
|
@ -848,6 +855,13 @@ async function initializeServices(): Promise<void> {
|
||||||
|
|
||||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
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;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
branchStatusService = new BranchStatusService((event) => {
|
branchStatusService = new BranchStatusService((event) => {
|
||||||
safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, 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).
|
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||||
forwardTeamChange(event);
|
forwardTeamChange(event);
|
||||||
|
teamTaskStallMonitor?.noteTeamChange(event);
|
||||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||||
teammateToolTracker?.handleTeamOffline(event.teamName);
|
teammateToolTracker?.handleTeamOffline(event.teamName);
|
||||||
}
|
}
|
||||||
|
|
@ -939,6 +954,7 @@ async function initializeServices(): Promise<void> {
|
||||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||||
});
|
});
|
||||||
|
teamTaskStallMonitor.start();
|
||||||
|
|
||||||
// Allow SchedulerService to push schedule events to renderer
|
// Allow SchedulerService to push schedule events to renderer
|
||||||
schedulerService.setChangeEmitter((event) => {
|
schedulerService.setChangeEmitter((event) => {
|
||||||
|
|
@ -1142,6 +1158,10 @@ function shutdownServices(): void {
|
||||||
if (teamDataService) {
|
if (teamDataService) {
|
||||||
teamDataService.stopProcessHealthPolling();
|
teamDataService.stopProcessHealthPolling();
|
||||||
}
|
}
|
||||||
|
if (teamTaskStallMonitor) {
|
||||||
|
void teamTaskStallMonitor.stop();
|
||||||
|
teamTaskStallMonitor = null;
|
||||||
|
}
|
||||||
branchStatusService?.dispose();
|
branchStatusService?.dispose();
|
||||||
branchStatusService = null;
|
branchStatusService = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1075,6 +1075,9 @@ async function handleUpdateConfig(
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('updateConfig', async () => {
|
return wrapTeamHandler('updateConfig', async () => {
|
||||||
const tn = validated.value!;
|
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, {
|
const result = await getTeamDataService().updateConfig(tn, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
|
@ -1085,10 +1088,10 @@ async function handleUpdateConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify running lead about the rename so it stays aware of current team name
|
// 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();
|
const provisioning = getTeamProvisioningService();
|
||||||
if (provisioning.isTeamAlive(tn)) {
|
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 {
|
try {
|
||||||
await provisioning.sendMessageToTeam(tn, msg);
|
await provisioning.sendMessageToTeam(tn, msg);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
|
|
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
|
||||||
lastPressureLogAt: number;
|
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 {
|
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
||||||
if (typeof value !== 'string') return '';
|
if (typeof value !== 'string') return '';
|
||||||
return value
|
return value
|
||||||
|
|
@ -500,6 +511,27 @@ export class TeamDataService {
|
||||||
return this.configReader.listTeams();
|
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[]> {
|
async getAllTasks(): Promise<GlobalTask[]> {
|
||||||
const rawTasks = await this.taskReader.getAllTasks();
|
const rawTasks = await this.taskReader.getAllTasks();
|
||||||
const teams = await this.configReader.listTeams();
|
const teams = await this.configReader.listTeams();
|
||||||
|
|
@ -1161,7 +1193,7 @@ export class TeamDataService {
|
||||||
role: configMember.role,
|
role: configMember.role,
|
||||||
workflow: configMember.workflow,
|
workflow: configMember.workflow,
|
||||||
agentType: configMember.agentType ?? 'general-purpose',
|
agentType: configMember.agentType ?? 'general-purpose',
|
||||||
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
|
color: configMember.color,
|
||||||
joinedAt: configMember.joinedAt ?? Date.now(),
|
joinedAt: configMember.joinedAt ?? Date.now(),
|
||||||
cwd: configMember.cwd,
|
cwd: configMember.cwd,
|
||||||
};
|
};
|
||||||
|
|
@ -1176,13 +1208,13 @@ export class TeamDataService {
|
||||||
member = {
|
member = {
|
||||||
name: memberName,
|
name: memberName,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(memberName),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
members.push(member);
|
const nextMembers = applyDistinctRosterColors([...members, member]);
|
||||||
await this.membersMetaStore.writeMembers(teamName, members);
|
member = nextMembers.find((m) => m.name === memberName) ?? member;
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { members, member };
|
return { members, member };
|
||||||
|
|
@ -1193,6 +1225,13 @@ export class TeamDataService {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Member name cannot be empty');
|
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);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -1224,12 +1263,11 @@ export class TeamDataService {
|
||||||
? request.effort
|
? request.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(name),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
members.push(newMember);
|
const nextMembers = applyDistinctRosterColors([...members, newMember]);
|
||||||
await this.membersMetaStore.writeMembers(teamName, members);
|
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
|
|
@ -1269,36 +1307,50 @@ export class TeamDataService {
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const nextByName = new Set<string>();
|
const nextByName = new Set<string>();
|
||||||
|
|
||||||
const nextActive: TeamMember[] = request.members.map((member) => {
|
const nextActive = applyDistinctRosterColors(
|
||||||
const name = member.name.trim();
|
request.members.map((member) => {
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
const name = member.name.trim();
|
||||||
if (name.toLowerCase() === 'team-lead') {
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
throw new Error('Member name "team-lead" is reserved');
|
const formatError = validateTeamMemberNameFormat(name);
|
||||||
}
|
if (formatError) {
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
}
|
||||||
throw new Error(
|
if (name.toLowerCase() === 'user') {
|
||||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
throw new Error('Member name "user" is reserved');
|
||||||
);
|
}
|
||||||
}
|
if (name.toLowerCase() === 'team-lead') {
|
||||||
nextByName.add(name.toLowerCase());
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
const prev = existingByName.get(name.toLowerCase());
|
}
|
||||||
return {
|
if (nextByName.has(name.toLowerCase())) {
|
||||||
name,
|
throw new Error(`Member "${name}" already exists`);
|
||||||
role: member.role?.trim() || undefined,
|
}
|
||||||
workflow: member.workflow?.trim() || undefined,
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
model: member.model?.trim() || undefined,
|
throw new Error(
|
||||||
effort:
|
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
);
|
||||||
? member.effort
|
}
|
||||||
: undefined,
|
nextByName.add(name.toLowerCase());
|
||||||
agentType: prev?.agentType ?? 'general-purpose',
|
const prev = existingByName.get(name.toLowerCase());
|
||||||
color: prev?.color ?? getMemberColorByName(name),
|
const isSameActiveMember = Boolean(prev && prev.removedAt == null);
|
||||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
return {
|
||||||
removedAt: undefined,
|
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.
|
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||||
const nextRemoved: TeamMember[] = [];
|
const nextRemoved: TeamMember[] = [];
|
||||||
|
|
@ -1712,6 +1764,23 @@ export class TeamDataService {
|
||||||
return result;
|
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 {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
if (!config) return 'team-lead';
|
if (!config) return 'team-lead';
|
||||||
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
||||||
|
|
@ -2323,12 +2392,18 @@ export class TeamDataService {
|
||||||
createdAt: joinedAt,
|
createdAt: joinedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctRosterColors(
|
||||||
request.teamName,
|
|
||||||
request.members.map((member) => ({
|
request.members.map((member) => ({
|
||||||
name: (() => {
|
name: (() => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
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')
|
if (name.toLowerCase() === 'team-lead')
|
||||||
throw new Error('Member name "team-lead" is reserved');
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
|
@ -2347,14 +2422,14 @@ export class TeamDataService {
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
? member.effort
|
? member.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(member.name.trim()),
|
|
||||||
joinedAt,
|
joinedAt,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: request.providerBackendId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileTeamArtifacts(
|
async reconcileTeamArtifacts(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ interface TeamLogSourceSnapshot {
|
||||||
logSourceGeneration: string | null;
|
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 {
|
interface TrackingState {
|
||||||
watcher: FSWatcher | null;
|
watcher: FSWatcher | null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
createCliProvisionerNameGuard,
|
createCliProvisionerNameGuard,
|
||||||
} from '@shared/utils/teamMemberName';
|
} from '@shared/utils/teamMemberName';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -262,6 +263,11 @@ export class TeamMemberResolver {
|
||||||
}
|
}
|
||||||
return aStableId.localeCompare(bStableId);
|
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),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
|
@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
import type { InboxMessage, TeamConfig } from '@shared/types';
|
import type { InboxMessage, TeamConfig } from '@shared/types';
|
||||||
|
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
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 {
|
interface TeamMessageFeedDeps {
|
||||||
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
|
@ -18,6 +21,7 @@ interface TeamMessageFeedDeps {
|
||||||
interface TeamMessageFeedCacheEntry {
|
interface TeamMessageFeedCacheEntry {
|
||||||
feedRevision: string;
|
feedRevision: string;
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamNormalizedMessageFeed {
|
export interface TeamNormalizedMessageFeed {
|
||||||
|
|
@ -352,7 +356,10 @@ export class TeamMessageFeedService {
|
||||||
|
|
||||||
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
|
||||||
const cached = this.cacheByTeam.get(teamName);
|
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 {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
feedRevision: cached.feedRevision,
|
feedRevision: cached.feedRevision,
|
||||||
|
|
@ -362,7 +369,7 @@ export class TeamMessageFeedService {
|
||||||
|
|
||||||
const config = await this.deps.getConfig(teamName);
|
const config = await this.deps.getConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
|
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now };
|
||||||
this.cacheByTeam.set(teamName, emptyEntry);
|
this.cacheByTeam.set(teamName, emptyEntry);
|
||||||
this.dirtyTeams.delete(teamName);
|
this.dirtyTeams.delete(teamName);
|
||||||
return { teamName, ...emptyEntry };
|
return { teamName, ...emptyEntry };
|
||||||
|
|
@ -389,12 +396,21 @@ export class TeamMessageFeedService {
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedRevision = toFeedRevision(messages);
|
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 =
|
const nextEntry =
|
||||||
cached?.feedRevision === feedRevision
|
cached?.feedRevision === feedRevision
|
||||||
? cached
|
? {
|
||||||
|
...cached,
|
||||||
|
cachedAt: now,
|
||||||
|
}
|
||||||
: {
|
: {
|
||||||
feedRevision,
|
feedRevision,
|
||||||
messages,
|
messages,
|
||||||
|
cachedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cacheByTeam.set(teamName, nextEntry);
|
this.cacheByTeam.set(teamName, nextEntry);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
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 { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||||
import {
|
import {
|
||||||
|
|
@ -225,6 +227,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
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 FS_MONITOR_POLL_MS = 2000;
|
||||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||||
const STALL_CHECK_INTERVAL_MS = 10_000;
|
const STALL_CHECK_INTERVAL_MS = 10_000;
|
||||||
|
|
@ -733,6 +745,8 @@ interface ProvisioningRun {
|
||||||
>;
|
>;
|
||||||
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
||||||
memberSpawnToolUseIds: Map<string, string>;
|
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. */
|
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||||
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
||||||
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||||
|
|
@ -827,6 +841,78 @@ interface LiveTeamAgentRuntimeMetadata {
|
||||||
tmuxPaneId?: string;
|
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(
|
function normalizeTeamAgentRuntimeBackendType(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
isLead: boolean
|
isLead: boolean
|
||||||
|
|
@ -1000,19 +1086,60 @@ function sleep(ms: number): Promise<void> {
|
||||||
async function waitForPidsToExit(
|
async function waitForPidsToExit(
|
||||||
pids: readonly number[],
|
pids: readonly number[],
|
||||||
opts: { timeoutMs: number; pollMs: number }
|
opts: { timeoutMs: number; pollMs: number }
|
||||||
): Promise<void> {
|
): Promise<number[]> {
|
||||||
if (pids.length === 0) {
|
if (pids.length === 0) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadline = Date.now() + opts.timeoutMs;
|
const deadline = Date.now() + opts.timeoutMs;
|
||||||
|
let remainingPids = [...new Set(pids)];
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const remaining = pids.filter((pid) => isProcessAlive(pid));
|
remainingPids = remainingPids.filter((pid) => isProcessAlive(pid));
|
||||||
if (remaining.length === 0) {
|
if (remainingPids.length === 0) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
await sleep(opts.pollMs);
|
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(
|
async function waitForChildProcessToExit(
|
||||||
|
|
@ -1646,7 +1773,9 @@ export function buildRestartMemberSpawnMessage(
|
||||||
return (
|
return (
|
||||||
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
|
`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. ` +
|
`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, ' ')
|
indentMultiline(prompt, ' ')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3937,6 +4066,7 @@ export class TeamProvisioningService {
|
||||||
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
|
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
|
||||||
if (spawnedMemberName) {
|
if (spawnedMemberName) {
|
||||||
run.memberSpawnToolUseIds.delete(toolUseId);
|
run.memberSpawnToolUseIds.delete(toolUseId);
|
||||||
|
const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName);
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const resultPreview = extractToolResultPreview(resultContent);
|
const resultPreview = extractToolResultPreview(resultContent);
|
||||||
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
|
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
|
||||||
|
|
@ -3946,8 +4076,42 @@ export class TeamProvisioningService {
|
||||||
const detail =
|
const detail =
|
||||||
parsedStatus.reason === 'already_running'
|
parsedStatus.reason === 'already_running'
|
||||||
? 'duplicate spawn skipped - 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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3965,11 +4129,16 @@ export class TeamProvisioningService {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
resultPreview?: string
|
resultPreview?: string
|
||||||
): void {
|
): void {
|
||||||
|
const pendingRestart = run.pendingMemberRestarts.get(memberName);
|
||||||
const reason =
|
const reason =
|
||||||
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
|
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
|
||||||
? resultPreview.trim()
|
? resultPreview.trim()
|
||||||
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
|
: '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);
|
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.
|
* Update spawn status for a specific team member and emit a change event.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4034,6 +4220,21 @@ export class TeamProvisioningService {
|
||||||
heartbeatAt?: string
|
heartbeatAt?: string
|
||||||
): void {
|
): void {
|
||||||
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
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 updatedAt = nowIso();
|
||||||
const next: MemberSpawnStatusEntry = {
|
const next: MemberSpawnStatusEntry = {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -4042,13 +4243,26 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'spawning') {
|
if (status === 'spawning') {
|
||||||
next.launchState = 'starting';
|
next.agentToolAccepted = false;
|
||||||
} else if (status === 'waiting') {
|
next.runtimeAlive = false;
|
||||||
next.agentToolAccepted = true;
|
next.bootstrapConfirmed = false;
|
||||||
next.hardFailure = false;
|
next.hardFailure = false;
|
||||||
next.error = undefined;
|
next.error = undefined;
|
||||||
next.hardFailureReason = 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.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
||||||
|
next.lastHeartbeatAt = undefined;
|
||||||
next.launchState = 'runtime_pending_bootstrap';
|
next.launchState = 'runtime_pending_bootstrap';
|
||||||
} else if (status === 'online') {
|
} else if (status === 'online') {
|
||||||
next.agentToolAccepted = true;
|
next.agentToolAccepted = true;
|
||||||
|
|
@ -4076,6 +4290,11 @@ export class TeamProvisioningService {
|
||||||
next.launchState = 'failed_to_start';
|
next.launchState = 'failed_to_start';
|
||||||
} else if (status === 'offline') {
|
} else if (status === 'offline') {
|
||||||
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
|
Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt });
|
||||||
|
next.error = undefined;
|
||||||
|
next.hardFailureReason = undefined;
|
||||||
|
next.livenessSource = undefined;
|
||||||
|
next.firstSpawnAcceptedAt = undefined;
|
||||||
|
next.lastHeartbeatAt = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
next.launchState = deriveMemberLaunchState(next);
|
next.launchState = deriveMemberLaunchState(next);
|
||||||
|
|
@ -4096,6 +4315,13 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
run.memberSpawnStatuses.set(memberName, next);
|
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);
|
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
||||||
|
|
||||||
if (status === 'spawning') {
|
if (status === 'spawning') {
|
||||||
|
|
@ -4334,11 +4560,38 @@ export class TeamProvisioningService {
|
||||||
throw new Error(`Team "${teamName}" is not currently running`);
|
throw new Error(`Team "${teamName}" is not currently running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const readCurrentConfiguredMember = async (): Promise<{
|
||||||
const configuredMembers = config?.members ?? [];
|
config: TeamConfig | null;
|
||||||
const configuredMember = configuredMembers.find(
|
configuredMembers: TeamConfig['members'];
|
||||||
(member) => member?.name?.trim() === memberName
|
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) {
|
if (!configuredMember) {
|
||||||
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
|
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 })) {
|
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
|
||||||
throw new Error('Lead restart is not supported from member controls');
|
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 persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
|
||||||
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
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 liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
||||||
const livePids = new Set<number>();
|
const livePids = new Set<number>();
|
||||||
let hasAliveRuntimeWithoutPid = false;
|
let hasAliveRuntimeWithoutPid = false;
|
||||||
|
|
@ -4387,6 +4645,7 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tmuxPaneIdsToVerify: string[] = [];
|
||||||
for (const persistedRuntimeMember of persistedRuntimeMembers) {
|
for (const persistedRuntimeMember of persistedRuntimeMembers) {
|
||||||
const paneId =
|
const paneId =
|
||||||
typeof persistedRuntimeMember.tmuxPaneId === 'string'
|
typeof persistedRuntimeMember.tmuxPaneId === 'string'
|
||||||
|
|
@ -4396,6 +4655,7 @@ export class TeamProvisioningService {
|
||||||
if (!paneId || backendType !== 'tmux') {
|
if (!paneId || backendType !== 'tmux') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
tmuxPaneIdsToVerify.push(paneId);
|
||||||
try {
|
try {
|
||||||
killTmuxPaneForCurrentPlatformSync(paneId);
|
killTmuxPaneForCurrentPlatformSync(paneId);
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4423,26 +4683,94 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (livePids.size > 0) {
|
if (livePids.size > 0) {
|
||||||
await waitForPidsToExit(Array.from(livePids), {
|
const lingeringPids = await waitForPidsToExit(Array.from(livePids), {
|
||||||
timeoutMs: 1_500,
|
timeoutMs: 1_500,
|
||||||
pollMs: 100,
|
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.agentRuntimeSnapshotCache.delete(teamName);
|
||||||
this.liveTeamAgentRuntimeMetadataCache.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.setMemberSpawnStatus(run, memberName, 'spawning');
|
||||||
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
|
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 =
|
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
|
||||||
configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead';
|
|
||||||
const restartMessage = buildRestartMemberSpawnMessage(
|
const restartMessage = buildRestartMemberSpawnMessage(
|
||||||
teamName,
|
teamName,
|
||||||
config?.name?.trim() || teamName,
|
config?.name?.trim() || teamName,
|
||||||
leadName,
|
leadName,
|
||||||
{
|
{
|
||||||
name: memberName,
|
name: configuredMember.name,
|
||||||
role: configuredMember.role,
|
role: configuredMember.role,
|
||||||
workflow: configuredMember.workflow,
|
workflow: configuredMember.workflow,
|
||||||
providerId: configuredMember.providerId,
|
providerId: configuredMember.providerId,
|
||||||
|
|
@ -4454,6 +4782,7 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToRun(run, restartMessage);
|
await this.sendMessageToRun(run, restartMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
this.setMemberSpawnStatus(
|
this.setMemberSpawnStatus(
|
||||||
run,
|
run,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -4483,6 +4812,10 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!entry.firstSpawnAcceptedAt) {
|
if (!entry.firstSpawnAcceptedAt) {
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
this.pendingTimeouts.delete(key);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const remainingMs =
|
const remainingMs =
|
||||||
|
|
@ -4530,11 +4863,17 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||||
|
if (restartPending) {
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
|
}
|
||||||
this.setMemberSpawnStatus(
|
this.setMemberSpawnStatus(
|
||||||
run,
|
run,
|
||||||
memberName,
|
memberName,
|
||||||
'error',
|
'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()])
|
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
pendingMemberRestarts: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
|
|
@ -6074,8 +6414,7 @@ export class TeamProvisioningService {
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||||
request.teamName,
|
|
||||||
effectiveMemberSpecs.map((m) => ({
|
effectiveMemberSpecs.map((m) => ({
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
role: m.role?.trim() || undefined,
|
role: m.role?.trim() || undefined,
|
||||||
|
|
@ -6087,13 +6426,13 @@ export class TeamProvisioningService {
|
||||||
? m.effort
|
? m.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose' as const,
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(m.name.trim()),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: request.providerBackendId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||||
if (request.skipPermissions === false) {
|
if (request.skipPermissions === false) {
|
||||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||||
}
|
}
|
||||||
|
|
@ -6550,6 +6889,7 @@ export class TeamProvisioningService {
|
||||||
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
pendingMemberRestarts: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
|
|
@ -8053,13 +8393,29 @@ export class TeamProvisioningService {
|
||||||
const nextStatuses = { ...statuses };
|
const nextStatuses = { ...statuses };
|
||||||
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
||||||
const current = nextStatuses[memberName];
|
const current = nextStatuses[memberName];
|
||||||
if (!current || !metadata.model) {
|
if (!current) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nextStatuses[memberName] = {
|
const nextEntry: MemberSpawnStatusEntry = {
|
||||||
...current,
|
...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;
|
return nextStatuses;
|
||||||
}
|
}
|
||||||
|
|
@ -8107,6 +8463,87 @@ export class TeamProvisioningService {
|
||||||
return undefined;
|
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(
|
private findEffectiveRunMemberModel(
|
||||||
run: ProvisioningRun | null,
|
run: ProvisioningRun | null,
|
||||||
memberName: string
|
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 ?? []) {
|
for (const member of run?.effectiveMembers ?? []) {
|
||||||
const memberName = member.name?.trim() ?? '';
|
const memberName = member.name?.trim() ?? '';
|
||||||
if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') {
|
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()) {
|
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||||
const backendType = metadata.backendType;
|
const backendType = metadata.backendType;
|
||||||
const panePid = paneId ? panePidById.get(paneId) : undefined;
|
const panePid = paneId ? panePidById.get(paneId) : undefined;
|
||||||
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined;
|
||||||
const alive =
|
const resolvedPid =
|
||||||
typeof panePid === 'number' && panePid > 0
|
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
|
? true
|
||||||
: backendType === 'tmux'
|
: backendType === 'tmux'
|
||||||
? false
|
? false
|
||||||
: Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
|
: mayInferAliveFromStatusOnly &&
|
||||||
|
Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
|
||||||
metadataByMember.set(memberName, {
|
metadataByMember.set(memberName, {
|
||||||
...metadata,
|
...metadata,
|
||||||
alive,
|
alive,
|
||||||
...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}),
|
...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8310,6 +8781,46 @@ export class TeamProvisioningService {
|
||||||
return rows;
|
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>> {
|
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
|
||||||
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
||||||
if (uniquePids.length === 0) {
|
if (uniquePids.length === 0) {
|
||||||
|
|
@ -8518,6 +9029,7 @@ export class TeamProvisioningService {
|
||||||
const nextMembers = { ...persisted.members };
|
const nextMembers = { ...persisted.members };
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
for (const expected of persisted.expectedMembers) {
|
for (const expected of persisted.expectedMembers) {
|
||||||
|
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
||||||
const current = nextMembers[expected] ?? {
|
const current = nextMembers[expected] ?? {
|
||||||
name: expected,
|
name: expected,
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
|
|
@ -8527,6 +9039,20 @@ export class TeamProvisioningService {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
lastEvaluatedAt: now,
|
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) => {
|
const matchedRuntimeNames = [...configMembers].filter((name) => {
|
||||||
if (name === expected) return true;
|
if (name === expected) return true;
|
||||||
const parsed = parseNumericSuffixName(name);
|
const parsed = parseNumericSuffixName(name);
|
||||||
|
|
@ -8573,6 +9099,21 @@ export class TeamProvisioningService {
|
||||||
: current.sources?.configDrift,
|
: current.sources?.configDrift,
|
||||||
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
|
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) {
|
if (heartbeatReason) {
|
||||||
current.hardFailure = true;
|
current.hardFailure = true;
|
||||||
current.hardFailureReason = heartbeatReason;
|
current.hardFailureReason = heartbeatReason;
|
||||||
|
|
@ -9405,6 +9946,16 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outcome === 'already_running') {
|
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');
|
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -13117,8 +13668,7 @@ export class TeamProvisioningService {
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||||
teamName,
|
|
||||||
teammateMembers.map((member) => ({
|
teammateMembers.map((member) => ({
|
||||||
name: member.name.trim(),
|
name: member.name.trim(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
|
|
@ -13129,14 +13679,14 @@ export class TeamProvisioningService {
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
? member.effort
|
? member.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(member.name.trim()),
|
|
||||||
joinedAt,
|
joinedAt,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: request.providerBackendId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Failed to persist members.meta.json: ${
|
`[${teamName}] Failed to persist members.meta.json: ${
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,12 @@ export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
export { TeamTaskReader } from './TeamTaskReader';
|
export { TeamTaskReader } from './TeamTaskReader';
|
||||||
export { TeamTaskWriter } from './TeamTaskWriter';
|
export { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
export { countLineChanges } from './UnifiedLineCounter';
|
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';
|
||||||
|
|
|
||||||
101
src/main/services/team/stallMonitor/ActiveTeamRegistry.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/main/services/team/stallMonitor/TeamTaskStallJournal.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
508
src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts
Normal 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('_', ' ')}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/main/services/team/stallMonitor/TeamTaskStallTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
42
src/main/services/team/stallMonitor/featureGates.ts
Normal 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);
|
||||||
|
}
|
||||||
47
src/main/services/team/stallMonitor/reviewerResolution.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -312,6 +312,21 @@ function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityR
|
||||||
return left.id.localeCompare(right.id);
|
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 {
|
export class BoardTaskActivityRecordBuilder {
|
||||||
buildForTask(args: {
|
buildForTask(args: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -319,64 +334,98 @@ export class BoardTaskActivityRecordBuilder {
|
||||||
tasks: TeamTask[];
|
tasks: TeamTask[];
|
||||||
messages: RawTaskActivityMessage[];
|
messages: RawTaskActivityMessage[];
|
||||||
}): BoardTaskActivityRecord[] {
|
}): 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 lookup = buildTaskLookup(args.tasks);
|
||||||
const records: BoardTaskActivityRecord[] = [];
|
const recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
|
||||||
const seenIds = new Set<string>();
|
const seenIdsByTaskId = new Map<string, Set<string>>();
|
||||||
|
|
||||||
for (const message of args.messages) {
|
for (const message of args.messages) {
|
||||||
const actionMap = buildActionMap(message.boardTaskToolActions);
|
const actionMap = buildActionMap(message.boardTaskToolActions);
|
||||||
|
|
||||||
for (const link of message.boardTaskLinks) {
|
for (const link of message.boardTaskLinks) {
|
||||||
const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup);
|
const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup);
|
||||||
if (
|
const candidateTaskIds = resolveCandidateTaskIds(link.task, lookup);
|
||||||
resolvedTask.taskRef?.taskId !== args.targetTask.id &&
|
if (candidateTaskIds.length === 0) {
|
||||||
!locatorCouldMatchTask(link.task, args.targetTask, lookup)
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action =
|
const action =
|
||||||
link.linkKind === 'execution' || !link.toolUseId
|
link.linkKind === 'execution' || !link.toolUseId
|
||||||
? undefined
|
? undefined
|
||||||
: actionMap.get(link.toolUseId);
|
: 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)) {
|
for (const taskId of candidateTaskIds) {
|
||||||
continue;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
|
||||||
|
|
||||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
||||||
interface ListTeamsPayload {
|
interface ListTeamsPayload {
|
||||||
teamsDir: string;
|
teamsDir: string;
|
||||||
|
|
@ -593,6 +594,11 @@ async function listTeams(
|
||||||
dropCliProvisionerMembers(memberMap);
|
dropCliProvisionerMembers(memberMap);
|
||||||
|
|
||||||
const members = Array.from(memberMap.values());
|
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 =
|
const launchStateSummary =
|
||||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
(await readLaunchState(payload.teamsDir, teamName)) ??
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -623,7 +629,7 @@ async function listTeams(
|
||||||
memberCount: memberMap.size,
|
memberCount: memberMap.size,
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
lastActivity: null,
|
lastActivity: null,
|
||||||
...(members.length > 0 ? { members } : {}),
|
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
|
||||||
BIN
src/renderer/assets/participant-avatars/01.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src/renderer/assets/participant-avatars/02.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/renderer/assets/participant-avatars/03.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src/renderer/assets/participant-avatars/04.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/renderer/assets/participant-avatars/05.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/renderer/assets/participant-avatars/06.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/renderer/assets/participant-avatars/07.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/renderer/assets/participant-avatars/08.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/renderer/assets/participant-avatars/09.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/renderer/assets/participant-avatars/10.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/renderer/assets/participant-avatars/11.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
src/renderer/assets/participant-avatars/12.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/renderer/assets/participant-avatars/13.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTeamColorSet,
|
getTeamColorSet,
|
||||||
getThemedBadge,
|
getThemedBadge,
|
||||||
|
|
@ -5,7 +7,13 @@ import {
|
||||||
getThemedText,
|
getThemedText,
|
||||||
} from '@renderer/constants/teamColors';
|
} from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
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';
|
import { MemberHoverCard } from './members/MemberHoverCard';
|
||||||
|
|
||||||
|
|
@ -40,6 +48,12 @@ export const MemberBadge = ({
|
||||||
}: MemberBadgeProps): React.JSX.Element => {
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(color ?? '');
|
const colors = getTeamColorSet(color ?? '');
|
||||||
const { isLight } = useTheme();
|
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 avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
||||||
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
||||||
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
||||||
|
|
@ -53,7 +67,7 @@ export const MemberBadge = ({
|
||||||
|
|
||||||
const avatar = (
|
const avatar = (
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(name, avatarSize)}
|
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -75,10 +76,11 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||||
|
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
|
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
|
||||||
|
|
@ -105,6 +107,10 @@ import { ScheduleSection } from './schedule/ScheduleSection';
|
||||||
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||||||
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||||||
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||||||
|
import {
|
||||||
|
getTeamPendingRepliesState,
|
||||||
|
setTeamPendingRepliesState,
|
||||||
|
} from './sidebar/teamSidebarUiState';
|
||||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
|
|
@ -126,6 +132,8 @@ import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamCreateRequest,
|
||||||
|
TeamLaunchRequest,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
@ -910,7 +918,9 @@ export const TeamDetailView = ({
|
||||||
initialTab?: MemberDetailTab;
|
initialTab?: MemberDetailTab;
|
||||||
initialActivityFilter?: MemberActivityFilter;
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
|
||||||
|
getTeamPendingRepliesState(teamName)
|
||||||
|
);
|
||||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
defaultSubject: '',
|
defaultSubject: '',
|
||||||
|
|
@ -923,7 +933,13 @@ export const TeamDetailView = ({
|
||||||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||||
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||||||
const [editDialogOpen, setEditDialogOpen] = 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 [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [graphOpen, setGraphOpen] = useState(false);
|
const [graphOpen, setGraphOpen] = useState(false);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -1155,6 +1171,7 @@ export const TeamDetailView = ({
|
||||||
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
|
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
|
||||||
{ teamName: string; displayName: string; projectPath: string }[]
|
{ teamName: string; displayName: string; projectPath: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const launchDialogOpen = launchDialogState.open;
|
||||||
|
|
||||||
// Session loading and filtering state
|
// Session loading and filtering state
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
|
@ -1200,6 +1217,8 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
|
refreshMemberActivityMeta,
|
||||||
syncTeamPendingReplyRefresh,
|
syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
|
@ -1251,6 +1270,8 @@ export const TeamDetailView = ({
|
||||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
|
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
|
||||||
|
refreshMemberActivityMeta: s.refreshMemberActivityMeta,
|
||||||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
|
|
@ -1274,6 +1295,7 @@ export const TeamDetailView = ({
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
const activeTabId = useStore((s) => s.activeTabId);
|
const activeTabId = useStore((s) => s.activeTabId);
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
|
const wasInteractiveRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -1337,6 +1359,14 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [tabId, initTabUIState]);
|
}, [tabId, initTabUIState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
|
||||||
|
}, [pendingRepliesByMember, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasProvisioning = wasProvisioningRef.current;
|
const wasProvisioning = wasProvisioningRef.current;
|
||||||
wasProvisioningRef.current = isTeamProvisioning;
|
wasProvisioningRef.current = isTeamProvisioning;
|
||||||
|
|
@ -1375,6 +1405,32 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
|
}, [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)
|
// Fetch active teams when launch dialog opens (for conflict warning)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!launchDialogOpen) return;
|
if (!launchDialogOpen) return;
|
||||||
|
|
@ -1537,6 +1593,10 @@ export const TeamDetailView = ({
|
||||||
return nextMember;
|
return nextMember;
|
||||||
});
|
});
|
||||||
}, [leadBranch, members, trackedBranches]);
|
}, [leadBranch, members, trackedBranches]);
|
||||||
|
const resolvedMemberColorMap = useMemo(
|
||||||
|
() => buildMemberColorMap(membersWithLiveBranches),
|
||||||
|
[membersWithLiveBranches]
|
||||||
|
);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessionIds = useMemo(() => {
|
const teamSessionIds = useMemo(() => {
|
||||||
|
|
@ -1661,10 +1721,49 @@ export const TeamDetailView = ({
|
||||||
setSendDialogOpen(true);
|
setSendDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRestartTeam = useCallback(() => {
|
const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => {
|
||||||
setLaunchDialogOpen(true);
|
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) => {
|
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView(null);
|
setSelectedMemberView(null);
|
||||||
|
|
@ -1912,6 +2011,7 @@ export const TeamDetailView = ({
|
||||||
onReplyToMessage: handleReplyToMessage,
|
onReplyToMessage: handleReplyToMessage,
|
||||||
onRestartTeam: handleRestartTeam,
|
onRestartTeam: handleRestartTeam,
|
||||||
onTaskIdClick: handleTaskIdClick,
|
onTaskIdClick: handleTaskIdClick,
|
||||||
|
inlineScrollContainerRef: contentRef,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeMembers,
|
activeMembers,
|
||||||
|
|
@ -2010,7 +2110,7 @@ export const TeamDetailView = ({
|
||||||
<div className="mt-4 flex justify-center gap-2">
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
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
|
Launch
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2027,17 +2127,16 @@ export const TeamDetailView = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode="launch"
|
mode={launchDialogState.mode}
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={[]}
|
members={[]}
|
||||||
defaultProjectPath={draftTeamSummary?.projectPath}
|
defaultProjectPath={draftTeamSummary?.projectPath}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
clearProvisioningError={clearProvisioningError}
|
clearProvisioningError={clearProvisioningError}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
onClose={closeLaunchDialog}
|
||||||
onLaunch={async (request) => {
|
onLaunch={handleLaunchDialogSubmit}
|
||||||
await launchTeam(request);
|
onRelaunch={handleRelaunchDialogSubmit}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -2168,12 +2267,17 @@ export const TeamDetailView = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
disabled={isTeamProvisioning}
|
||||||
onClick={() => setEditDialogOpen(true)}
|
onClick={() => setEditDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</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>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -2294,7 +2398,7 @@ export const TeamDetailView = ({
|
||||||
{!data.isAlive && !isTeamProvisioning ? (
|
{!data.isAlive && !isTeamProvisioning ? (
|
||||||
<TeamOfflineStatusBanner
|
<TeamOfflineStatusBanner
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
onLaunch={() => setLaunchDialogOpen(true)}
|
onLaunch={() => openLaunchDialog('launch')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -2708,9 +2812,13 @@ export const TeamDetailView = ({
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
currentColor={data.config.color ?? ''}
|
||||||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||||
|
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
|
||||||
|
resolvedMemberColorMap={resolvedMemberColorMap}
|
||||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onChangeLeadRuntime={handleChangeLeadRuntime}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -2801,7 +2909,7 @@ export const TeamDetailView = ({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode="launch"
|
mode={launchDialogState.mode}
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={membersWithLiveBranches}
|
members={membersWithLiveBranches}
|
||||||
|
|
@ -2809,10 +2917,9 @@ export const TeamDetailView = ({
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
clearProvisioningError={clearProvisioningError}
|
clearProvisioningError={clearProvisioningError}
|
||||||
activeTeams={activeTeamsForLaunch}
|
activeTeams={activeTeamsForLaunch}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
onClose={closeLaunchDialog}
|
||||||
onLaunch={async (request) => {
|
onLaunch={handleLaunchDialogSubmit}
|
||||||
await launchTeam(request);
|
onRelaunch={handleRelaunchDialogSubmit}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SendMessageDialog
|
<SendMessageDialog
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -42,6 +43,7 @@ export const ActiveTasksBlock = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
const avatarMap = buildMemberAvatarMap(members);
|
||||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||||
|
|
||||||
const entries: ActivityEntry[] = [];
|
const entries: ActivityEntry[] = [];
|
||||||
|
|
@ -115,7 +117,7 @@ export const ActiveTasksBlock = ({
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className="relative inline-flex shrink-0">
|
<span className="relative inline-flex shrink-0">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name, 24)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
import {
|
||||||
areInboxMessagesEquivalentForRender,
|
areInboxMessagesEquivalentForRender,
|
||||||
|
|
@ -6,6 +14,7 @@ import {
|
||||||
areStringMapsEqual,
|
areStringMapsEqual,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
|
@ -21,9 +30,60 @@ import {
|
||||||
} from './LeadThoughtsGroup';
|
} from './LeadThoughtsGroup';
|
||||||
import { useNewItemKeys } from './useNewItemKeys';
|
import { useNewItemKeys } from './useNewItemKeys';
|
||||||
|
|
||||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup';
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
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 {
|
interface ActivityTimelineProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -66,6 +126,14 @@ interface ActivityTimelineProps {
|
||||||
onExpandItem?: (key: string) => void;
|
onExpandItem?: (key: string) => void;
|
||||||
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
|
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
|
||||||
onExpandContent?: () => void;
|
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;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
|
@ -74,6 +142,59 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
|
||||||
const EMPTY_TEAM_NAMES: string[] = [];
|
const EMPTY_TEAM_NAMES: string[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
||||||
|
const 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 {
|
function getItemSessionAnchorId(item: TimelineItem): string | undefined {
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
|
|
@ -141,6 +262,7 @@ const MessageRowWithObserver = ({
|
||||||
onExpand,
|
onExpand,
|
||||||
expandItemKey,
|
expandItemKey,
|
||||||
onExpandContent,
|
onExpandContent,
|
||||||
|
observerRoot,
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -170,6 +292,7 @@ const MessageRowWithObserver = ({
|
||||||
onExpand?: (key: string) => void;
|
onExpand?: (key: string) => void;
|
||||||
expandItemKey?: string;
|
expandItemKey?: string;
|
||||||
onExpandContent?: () => void;
|
onExpandContent?: () => void;
|
||||||
|
observerRoot?: RefObject<HTMLElement | null>;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const reportedRef = useRef(false);
|
||||||
|
|
@ -185,6 +308,10 @@ const MessageRowWithObserver = ({
|
||||||
if (!onVisible) return;
|
if (!onVisible) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
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(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (!entry?.isIntersecting) return;
|
if (!entry?.isIntersecting) return;
|
||||||
|
|
@ -195,11 +322,11 @@ const MessageRowWithObserver = ({
|
||||||
reportedRef.current = true;
|
reportedRef.current = true;
|
||||||
cb(msg);
|
cb(msg);
|
||||||
},
|
},
|
||||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible]);
|
}, [onVisible, observerRoot]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
||||||
|
|
@ -265,6 +392,7 @@ const MemoizedMessageRowWithObserver = React.memo(
|
||||||
prev.onExpand === next.onExpand &&
|
prev.onExpand === next.onExpand &&
|
||||||
prev.expandItemKey === next.expandItemKey &&
|
prev.expandItemKey === next.expandItemKey &&
|
||||||
prev.onExpandContent === next.onExpandContent &&
|
prev.onExpandContent === next.onExpandContent &&
|
||||||
|
prev.observerRoot === next.observerRoot &&
|
||||||
areInboxMessagesEquivalentForRender(prev.message, next.message)
|
areInboxMessagesEquivalentForRender(prev.message, next.message)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -291,7 +419,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
onTeamClick,
|
onTeamClick,
|
||||||
onExpandItem,
|
onExpandItem,
|
||||||
onExpandContent,
|
onExpandContent,
|
||||||
|
viewport,
|
||||||
}: ActivityTimelineProps): React.JSX.Element {
|
}: ActivityTimelineProps): React.JSX.Element {
|
||||||
|
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
|
||||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [compactHeader, setCompactHeader] = useState(false);
|
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 pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
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).
|
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
|
||||||
const newestMessageIndex = useMemo(() => {
|
const newestMessageIndex = useMemo(() => {
|
||||||
return findNewestMessageIndex(timelineItems);
|
return findNewestMessageIndex(timelineItems);
|
||||||
|
|
@ -485,6 +738,124 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
|
[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) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
|
<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 (
|
return (
|
||||||
<div ref={rootRef} className="space-y-1">
|
<div ref={rootRef} className="space-y-1">
|
||||||
{/* Pinned (newest) thought group — always at top */}
|
{shouldVirtualize ? (
|
||||||
{pinnedThoughtGroup &&
|
<div
|
||||||
(() => {
|
style={{
|
||||||
const { group } = pinnedThoughtGroup;
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
const firstThought = group.thoughts[0];
|
width: '100%',
|
||||||
const pinnedCanBeLive = currentLeadSessionId
|
position: 'relative',
|
||||||
? firstThought.leadSessionId === currentLeadSessionId
|
}}
|
||||||
: true;
|
>
|
||||||
const info = memberInfo.get(firstThought.from);
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const itemKey = getThoughtGroupKey(group);
|
const row = renderRows[virtualRow.index];
|
||||||
const stableKey = itemKey;
|
if (!row) return null;
|
||||||
const collapseProps = getItemCollapseProps(stableKey, 0);
|
return (
|
||||||
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 = (
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3"
|
key={virtualRow.key}
|
||||||
style={{ paddingTop: 45, paddingBottom: 45 }}
|
// `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" />
|
{renderTimelineRow(row, { suppressEntryAnimation: true })}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
}
|
</div>
|
||||||
|
) : (
|
||||||
if (item.type === 'lead-thoughts') {
|
renderRows.map((row) => renderTimelineRow(row))
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<div className="relative flex justify-center pb-3 pt-1">
|
<div className="relative flex justify-center pb-3 pt-1">
|
||||||
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
|
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
type JSX,
|
type JSX,
|
||||||
memo,
|
memo,
|
||||||
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
|
@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps {
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
onVisible?: (message: InboxMessage) => void;
|
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). */
|
/** When false, the live indicator is always off (for historical thought groups). */
|
||||||
canBeLive?: boolean;
|
canBeLive?: boolean;
|
||||||
/** Whether the owning team is currently alive. */
|
/** Whether the owning team is currently alive. */
|
||||||
|
|
@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
isNew,
|
isNew,
|
||||||
onVisible,
|
onVisible,
|
||||||
|
observerRoot,
|
||||||
canBeLive,
|
canBeLive,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
if (!onVisible) return;
|
if (!onVisible) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
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(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (!entry?.isIntersecting) return;
|
if (!entry?.isIntersecting) return;
|
||||||
|
|
@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
}
|
}
|
||||||
reportedCountRef.current = thoughts.length;
|
reportedCountRef.current = thoughts.length;
|
||||||
},
|
},
|
||||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [onVisible, observerRoot, thoughts]);
|
||||||
|
|
||||||
const clearPendingScrollSync = useCallback(() => {
|
const clearPendingScrollSync = useCallback(() => {
|
||||||
if (scrollSyncFrameRef.current !== null) {
|
if (scrollSyncFrameRef.current !== null) {
|
||||||
|
|
@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo(
|
||||||
prev.compactHeader === next.compactHeader &&
|
prev.compactHeader === next.compactHeader &&
|
||||||
prev.onExpand === next.onExpand &&
|
prev.onExpand === next.onExpand &&
|
||||||
prev.expandItemKey === next.expandItemKey &&
|
prev.expandItemKey === next.expandItemKey &&
|
||||||
|
prev.observerRoot === next.observerRoot &&
|
||||||
areThoughtGroupsEquivalent(prev.group, next.group)
|
areThoughtGroupsEquivalent(prev.group, next.group)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ function formatTime(timestamp: string): string {
|
||||||
|
|
||||||
interface DialogThoughtsContentProps {
|
interface DialogThoughtsContentProps {
|
||||||
group: LeadThoughtGroup;
|
group: LeadThoughtGroup;
|
||||||
|
members?: ResolvedTeamMember[];
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
|
|
@ -39,6 +40,7 @@ interface DialogThoughtsContentProps {
|
||||||
|
|
||||||
const DialogThoughtsContent = ({
|
const DialogThoughtsContent = ({
|
||||||
group,
|
group,
|
||||||
|
members,
|
||||||
memberColor,
|
memberColor,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onReply,
|
onReply,
|
||||||
|
|
@ -51,6 +53,7 @@ const DialogThoughtsContent = ({
|
||||||
const newest = thoughts[0];
|
const newest = thoughts[0];
|
||||||
const oldest = thoughts[thoughts.length - 1];
|
const oldest = thoughts[thoughts.length - 1];
|
||||||
const colors = getTeamColorSet(memberColor ?? '');
|
const colors = getTeamColorSet(memberColor ?? '');
|
||||||
|
const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]);
|
||||||
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,7 +61,7 @@ const DialogThoughtsContent = ({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 pb-3">
|
<div className="flex items-center gap-2 pb-3">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(newest.from, 32)}
|
src={avatarMap.get(newest.from) ?? agentAvatarUrl(newest.from, 32)}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -193,6 +196,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
|
||||||
) : displayItem?.type === 'lead-thoughts' ? (
|
) : displayItem?.type === 'lead-thoughts' ? (
|
||||||
<DialogThoughtsContent
|
<DialogThoughtsContent
|
||||||
group={displayItem.group}
|
group={displayItem.group}
|
||||||
|
members={members}
|
||||||
memberColor={thoughtMemberColor}
|
memberColor={thoughtMemberColor}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
|
|
@ -41,6 +42,7 @@ export const PendingRepliesBlock = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
const avatarMap = buildMemberAvatarMap(members);
|
||||||
const memberPending = Object.entries(pendingRepliesByMember)
|
const memberPending = Object.entries(pendingRepliesByMember)
|
||||||
.map(([name, sentAtMs]) => ({
|
.map(([name, sentAtMs]) => ({
|
||||||
kind: 'member' as const,
|
kind: 'member' as const,
|
||||||
|
|
@ -111,7 +113,7 @@ export const PendingRepliesBlock = ({
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className="relative inline-flex shrink-0">
|
<span className="relative inline-flex shrink-0">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name, 24)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import {
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -941,7 +942,14 @@ export const CreateTeamDialog = ({
|
||||||
const mentionSuggestions = useMemo(
|
const mentionSuggestions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
soloTeam
|
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),
|
: buildMemberDraftSuggestions(members, memberColorMap),
|
||||||
[memberColorMap, members, soloTeam]
|
[memberColorMap, members, soloTeam]
|
||||||
);
|
);
|
||||||
|
|
@ -1219,7 +1227,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
filterEditableMemberInputs,
|
filterEditableMemberInputs,
|
||||||
|
createMemberDraft,
|
||||||
MembersEditorSection,
|
MembersEditorSection,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -21,8 +23,21 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
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 { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildEditTeamSourceSnapshot,
|
||||||
|
getMemberRuntimeContractKey,
|
||||||
|
getLiveRosterIdentityChanges,
|
||||||
|
getMembersRequiringRuntimeRestart,
|
||||||
|
} from './editTeamRuntimeChanges';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
|
|
@ -43,16 +58,73 @@ interface EditTeamDialogProps {
|
||||||
currentDescription: string;
|
currentDescription: string;
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
currentMembers: ResolvedTeamMember[];
|
currentMembers: ResolvedTeamMember[];
|
||||||
|
leadMember?: ResolvedTeamMember | null;
|
||||||
|
resolvedMemberColorMap?: ReadonlyMap<string, string>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onChangeLeadRuntime: () => void;
|
||||||
|
onSaved: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||||
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
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 = ({
|
export const EditTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -60,9 +132,13 @@ export const EditTeamDialog = ({
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentColor,
|
currentColor,
|
||||||
currentMembers,
|
currentMembers,
|
||||||
|
leadMember = null,
|
||||||
|
resolvedMemberColorMap,
|
||||||
isTeamAlive = false,
|
isTeamAlive = false,
|
||||||
|
isTeamProvisioning = false,
|
||||||
projectPath,
|
projectPath,
|
||||||
onClose,
|
onClose,
|
||||||
|
onChangeLeadRuntime,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: EditTeamDialogProps): React.JSX.Element => {
|
}: EditTeamDialogProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
@ -72,39 +148,305 @@ export const EditTeamDialog = ({
|
||||||
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
|
const wasOpen = wasOpenRef.current;
|
||||||
if (open) {
|
if (open) {
|
||||||
setName(currentName);
|
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
|
||||||
setDescription(currentDescription);
|
if (shouldInitialize) {
|
||||||
setColor(currentColor);
|
setName(currentName);
|
||||||
setMembers(membersToDrafts(currentMembers));
|
setDescription(currentDescription);
|
||||||
setError(null);
|
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 => {
|
const handleSave = (): void => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Team name cannot be empty');
|
setError('Team name cannot be empty');
|
||||||
return;
|
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);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSaveOutcomeError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
let configSaved = false;
|
||||||
|
let membersSaved = false;
|
||||||
|
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
|
||||||
try {
|
try {
|
||||||
await api.teams.updateConfig(teamName, {
|
await api.teams.updateConfig(teamName, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
color,
|
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 });
|
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
||||||
onSaved();
|
membersSaved = true;
|
||||||
onClose();
|
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) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +455,7 @@ export const EditTeamDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Team</DialogTitle>
|
<DialogTitle>Edit Team</DialogTitle>
|
||||||
<DialogDescription>Change team name, description and color</DialogDescription>
|
<DialogDescription>Change team name, description and color</DialogDescription>
|
||||||
|
|
@ -131,7 +473,10 @@ export const EditTeamDialog = ({
|
||||||
id="edit-team-name"
|
id="edit-team-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
|
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
|
||||||
}}
|
}}
|
||||||
|
|
@ -149,7 +494,10 @@ export const EditTeamDialog = ({
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-team-description"
|
id="edit-team-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}}
|
||||||
rows={3}
|
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)]"
|
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)"
|
placeholder="Team description (optional)"
|
||||||
|
|
@ -158,20 +506,90 @@ export const EditTeamDialog = ({
|
||||||
<div>
|
<div>
|
||||||
<MembersEditorSection
|
<MembersEditorSection
|
||||||
members={members}
|
members={members}
|
||||||
onChange={setMembers}
|
onChange={(nextMembers) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setMembers(nextMembers);
|
||||||
|
}}
|
||||||
|
fieldError={invalidMemberNamesError ?? undefined}
|
||||||
validateMemberName={validateMemberNameInline}
|
validateMemberName={validateMemberNameInline}
|
||||||
showWorkflow
|
showWorkflow
|
||||||
showJsonEditor={!isTeamAlive}
|
showJsonEditor={!isTeamAlive}
|
||||||
draftKeyPrefix={`editTeam:${teamName}`}
|
draftKeyPrefix={`editTeam:${teamName}`}
|
||||||
projectPath={projectPath ?? null}
|
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}
|
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>
|
</div>
|
||||||
{isTeamAlive ? (
|
{isTeamProvisioning ? (
|
||||||
<p className="text-xs text-amber-300">
|
<p className="text-xs text-amber-300">
|
||||||
Provider and model changes are locked while the team is live. Reconnect the team to
|
Team provisioning is still in progress. Editing is temporarily locked until launch
|
||||||
change them safely.
|
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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -196,7 +614,10 @@ export const EditTeamDialog = ({
|
||||||
borderColor: isSelected ? colorSet.border : 'transparent',
|
borderColor: isSelected ? colorSet.border : 'transparent',
|
||||||
}}
|
}}
|
||||||
title={colorName}
|
title={colorName}
|
||||||
onClick={() => setColor(isSelected ? '' : colorName)}
|
onClick={() => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setColor(isSelected ? '' : colorName);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="size-3.5 rounded-full"
|
className="size-3.5 rounded-full"
|
||||||
|
|
@ -207,14 +628,26 @@ export const EditTeamDialog = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
|
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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" />}
|
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
Schedule,
|
Schedule,
|
||||||
ScheduleLaunchConfig,
|
ScheduleLaunchConfig,
|
||||||
|
TeamCreateRequest,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
|
|
@ -140,6 +141,8 @@ interface LaunchDialogBase {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamLaunchDialogMode = 'launch' | 'relaunch';
|
||||||
|
|
||||||
interface LaunchDialogLaunchMode extends LaunchDialogBase {
|
interface LaunchDialogLaunchMode extends LaunchDialogBase {
|
||||||
mode: 'launch';
|
mode: 'launch';
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
|
@ -150,6 +153,16 @@ interface LaunchDialogLaunchMode extends LaunchDialogBase {
|
||||||
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
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 {
|
interface LaunchDialogScheduleMode {
|
||||||
mode: 'schedule';
|
mode: 'schedule';
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -160,7 +173,10 @@ interface LaunchDialogScheduleMode {
|
||||||
schedule?: Schedule | null;
|
schedule?: Schedule | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode;
|
export type LaunchTeamDialogProps =
|
||||||
|
| LaunchDialogLaunchMode
|
||||||
|
| LaunchDialogRelaunchMode
|
||||||
|
| LaunchDialogScheduleMode;
|
||||||
|
|
||||||
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
|
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 cliStatus = useStore((s) => s.cliStatus);
|
||||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
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 isSchedule = props.mode === 'schedule';
|
||||||
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||||
const isEditing = isSchedule && !!schedule;
|
const isEditing = isSchedule && !!schedule;
|
||||||
|
|
@ -318,7 +335,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const previousLaunchParams = useStore((s) =>
|
const previousLaunchParams = useStore((s) =>
|
||||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
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 [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
|
||||||
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
|
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
|
|
@ -541,7 +558,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = (): void => {
|
const closeDialog = (): void => {
|
||||||
if (isLaunch) {
|
if (isLaunchMode) {
|
||||||
resetFormState();
|
resetFormState();
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -599,7 +616,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}, [open, isSchedule, schedule?.id]);
|
}, [open, isSchedule, schedule?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isLaunch) return;
|
if (!open || !isLaunchMode) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -669,10 +686,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
|
}, [open, isLaunchMode, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
|
||||||
|
|
||||||
const previousProviderId = useMemo<TeamProviderId | null>(() => {
|
const previousProviderId = useMemo<TeamProviderId | null>(() => {
|
||||||
if (!isLaunch) {
|
if (!isLaunchMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const fromLaunchParams = previousLaunchParams?.providerId;
|
const fromLaunchParams = previousLaunchParams?.providerId;
|
||||||
|
|
@ -684,14 +701,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return fromLaunchParams;
|
return fromLaunchParams;
|
||||||
}
|
}
|
||||||
return savedLaunchProviderId;
|
return savedLaunchProviderId;
|
||||||
}, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]);
|
}, [isLaunchMode, previousLaunchParams?.providerId, savedLaunchProviderId]);
|
||||||
|
|
||||||
const providerChangeForcesFreshLeadContext = useMemo(() => {
|
const providerChangeForcesFreshLeadContext = useMemo(() => {
|
||||||
if (!isLaunch || !previousProviderId) {
|
if (!isLaunchMode || !previousProviderId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return previousProviderId !== selectedProviderId;
|
return previousProviderId !== selectedProviderId;
|
||||||
}, [isLaunch, previousProviderId, selectedProviderId]);
|
}, [isLaunchMode, previousProviderId, selectedProviderId]);
|
||||||
|
|
||||||
const effectiveLeadRuntimeModel = useMemo(
|
const effectiveLeadRuntimeModel = useMemo(
|
||||||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
||||||
|
|
@ -744,7 +761,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]);
|
}, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]);
|
||||||
|
|
||||||
const runtimeChangeNotes = useMemo(() => {
|
const runtimeChangeNotes = useMemo(() => {
|
||||||
if (!isLaunch) {
|
if (!isLaunchMode) {
|
||||||
return [] as { key: string; memberName: string; message: string }[];
|
return [] as { key: string; memberName: string; message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -836,7 +853,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
return notes;
|
return notes;
|
||||||
}, [
|
}, [
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
previousLaunchParams?.effort,
|
previousLaunchParams?.effort,
|
||||||
previousLaunchParams?.model,
|
previousLaunchParams?.model,
|
||||||
previousProviderId,
|
previousProviderId,
|
||||||
|
|
@ -895,14 +912,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
// Clear stale provisioning error when dialog opens
|
// Clear stale provisioning error when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isLaunch) return;
|
if (!open || !isLaunchMode) return;
|
||||||
props.clearProvisioningError?.(effectiveTeamName);
|
props.clearProvisioningError?.(effectiveTeamName);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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).
|
// Warm up CLI for the currently selected working directory (launch mode only).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isLaunch) return;
|
if (!open || !isLaunchMode) return;
|
||||||
|
|
||||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
|
|
@ -1052,7 +1069,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedMemberProviders,
|
selectedMemberProviders,
|
||||||
|
|
@ -1111,7 +1128,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}, [open, repositoryGroups]);
|
}, [open, repositoryGroups]);
|
||||||
|
|
||||||
// Pre-select defaultProjectPath (launch mode) or first project
|
// Pre-select defaultProjectPath (launch mode) or first project
|
||||||
const defaultProjectPath = isLaunch ? props.defaultProjectPath : undefined;
|
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return;
|
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
|
// Launch-only: conflict detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const activeTeams = isLaunch ? props.activeTeams : undefined;
|
const activeTeams = isLaunchMode ? props.activeTeams : undefined;
|
||||||
|
|
||||||
const conflictingTeam = useMemo(() => {
|
const conflictingTeam = useMemo(() => {
|
||||||
if (!isLaunch || !activeTeams?.length || !effectiveCwd) return null;
|
if (!isLaunchMode || !activeTeams?.length || !effectiveCwd) return null;
|
||||||
const norm = normalizePath(effectiveCwd);
|
const norm = normalizePath(effectiveCwd);
|
||||||
return (
|
return (
|
||||||
activeTeams.find(
|
activeTeams.find(
|
||||||
(t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm
|
(t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}, [isLaunch, activeTeams, effectiveCwd, effectiveTeamName]);
|
}, [isLaunchMode, activeTeams, effectiveCwd, effectiveTeamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConflictDismissed(false);
|
setConflictDismissed(false);
|
||||||
|
|
@ -1168,7 +1185,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const internalArgs = useMemo(() => {
|
const internalArgs = useMemo(() => {
|
||||||
if (!isLaunch) return [];
|
if (!isLaunchMode) return [];
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
|
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
|
||||||
args.push('--verbose', '--setting-sources', 'user,project,local');
|
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>');
|
if (!clearContext) args.push('--resume', '<previous>');
|
||||||
return args;
|
return args;
|
||||||
}, [
|
}, [
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
limitContext,
|
limitContext,
|
||||||
|
|
@ -1190,7 +1207,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const launchOptionalSummary = useMemo(() => {
|
const launchOptionalSummary = useMemo(() => {
|
||||||
if (!isLaunch) return [];
|
if (!isLaunchMode) return [];
|
||||||
|
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
if (promptDraft.value.trim()) summary.push('Lead prompt');
|
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');
|
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||||
return summary;
|
return summary;
|
||||||
}, [
|
}, [
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
promptDraft.value,
|
promptDraft.value,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
|
|
@ -1241,7 +1258,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return leadError;
|
return leadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLaunch) {
|
if (!isLaunchMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1267,7 +1284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return null;
|
return null;
|
||||||
}, [
|
}, [
|
||||||
effectiveMemberDrafts,
|
effectiveMemberDrafts,
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
runtimeProviderStatusById,
|
runtimeProviderStatusById,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
|
|
@ -1282,7 +1299,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]);
|
}, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]);
|
||||||
const memberModelIssueById = useMemo(() => {
|
const memberModelIssueById = useMemo(() => {
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (!isLaunch) {
|
if (!isLaunchMode) {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
for (const member of effectiveMemberDrafts) {
|
for (const member of effectiveMemberDrafts) {
|
||||||
|
|
@ -1303,7 +1320,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return next;
|
return next;
|
||||||
}, [
|
}, [
|
||||||
effectiveMemberDrafts,
|
effectiveMemberDrafts,
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
leadModelIssueText,
|
leadModelIssueText,
|
||||||
prepareChecks,
|
prepareChecks,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
|
|
@ -1311,32 +1328,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
]);
|
]);
|
||||||
const hasInvalidLaunchMemberNames = useMemo(
|
const hasInvalidLaunchMemberNames = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isLaunch &&
|
isLaunchMode &&
|
||||||
membersDrafts.some(
|
membersDrafts.some(
|
||||||
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
|
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
|
||||||
),
|
),
|
||||||
[isLaunch, membersDrafts]
|
[isLaunchMode, membersDrafts]
|
||||||
);
|
);
|
||||||
const hasDuplicateLaunchMemberNames = useMemo(() => {
|
const hasDuplicateLaunchMemberNames = useMemo(() => {
|
||||||
if (!isLaunch) return false;
|
if (!isLaunchMode) return false;
|
||||||
const activeNames = membersDrafts
|
const activeNames = membersDrafts
|
||||||
.map((member) => member.name.trim().toLowerCase())
|
.map((member) => member.name.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return new Set(activeNames).size !== activeNames.length;
|
return new Set(activeNames).size !== activeNames.length;
|
||||||
}, [isLaunch, membersDrafts]);
|
}, [isLaunchMode, membersDrafts]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error
|
// Error
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const provisioningError = isLaunch ? props.provisioningError : null;
|
const provisioningError = isLaunchMode ? props.provisioningError : null;
|
||||||
const activeError = localError ?? modelValidationError ?? provisioningError;
|
const activeError = localError ?? modelValidationError ?? provisioningError;
|
||||||
const launchInFlight = useStore((s) =>
|
const launchInFlight = useStore((s) =>
|
||||||
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isLaunch || !effectiveTeamName || !launchInFlight) {
|
if (!open || !isLaunchMode || !effectiveTeamName || !launchInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1347,7 +1364,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
defaultProjectPath,
|
defaultProjectPath,
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
effectiveTeamName,
|
effectiveTeamName,
|
||||||
isLaunch,
|
isLaunchMode,
|
||||||
launchInFlight,
|
launchInFlight,
|
||||||
open,
|
open,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
|
|
@ -1366,12 +1383,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setLocalError(modelValidationError);
|
setLocalError(modelValidationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLaunch && !effectiveCwd) {
|
if (isLaunchMode && !effectiveCwd) {
|
||||||
setLocalError('Select working directory (cwd)');
|
setLocalError('Select working directory (cwd)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isLaunch &&
|
isLaunchMode &&
|
||||||
membersDrafts.some(
|
membersDrafts.some(
|
||||||
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
|
(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');
|
setLocalError('Fix member names before launch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLaunch) {
|
if (isLaunchMode) {
|
||||||
const activeNames = membersDrafts
|
const activeNames = membersDrafts
|
||||||
.map((member) => member.name.trim().toLowerCase())
|
.map((member) => member.name.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
@ -1393,11 +1410,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (isLaunch) {
|
if (isLaunchMode) {
|
||||||
await api.teams.replaceMembers(effectiveTeamName, {
|
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts);
|
||||||
members: buildMembersFromDrafts(effectiveMemberDrafts),
|
const launchRequest: TeamLaunchRequest = {
|
||||||
});
|
|
||||||
await props.onLaunch({
|
|
||||||
teamName: effectiveTeamName,
|
teamName: effectiveTeamName,
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
prompt: promptDraft.value.trim() || undefined,
|
||||||
|
|
@ -1417,7 +1432,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
extraCliArgs: customArgs.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);
|
openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1464,10 +1487,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
? err.message
|
? err.message
|
||||||
: isSchedule
|
: isSchedule
|
||||||
? 'Failed to save schedule'
|
? 'Failed to save schedule'
|
||||||
: 'Failed to launch team';
|
: isRelaunch
|
||||||
|
? 'Failed to relaunch team'
|
||||||
|
: 'Failed to launch team';
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
if (isLaunch) {
|
if (isLaunchMode) {
|
||||||
console.error('Failed to launch team from dialog:', err);
|
console.error(
|
||||||
|
isRelaunch
|
||||||
|
? 'Failed to relaunch team from dialog:'
|
||||||
|
: 'Failed to launch team from dialog:',
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -1479,7 +1509,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Disabled state
|
// Disabled state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const isDisabled = isLaunch
|
const isDisabled = isLaunchMode
|
||||||
? isSubmitting ||
|
? isSubmitting ||
|
||||||
launchInFlight ||
|
launchInFlight ||
|
||||||
validationErrors.length > 0 ||
|
validationErrors.length > 0 ||
|
||||||
|
|
@ -1492,13 +1522,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Dynamic labels
|
// 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 ? (
|
const dialogDescription = isLaunchMode ? (
|
||||||
<>
|
isRelaunch ? (
|
||||||
Start team <span className="font-mono font-medium">{effectiveTeamName}</span> via local Claude
|
<>
|
||||||
CLI.
|
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 ? (
|
) : isEditing ? (
|
||||||
`Editing schedule for team "${effectiveTeamName}"`
|
`Editing schedule for team "${effectiveTeamName}"`
|
||||||
) : effectiveTeamName ? (
|
) : effectiveTeamName ? (
|
||||||
|
|
@ -1507,15 +1550,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
'Schedule automatic Claude task execution'
|
'Schedule automatic Claude task execution'
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitLabel = isLaunch
|
const submitLabel = isLaunchMode
|
||||||
? prepareState === 'idle' || prepareState === 'loading'
|
? isRelaunch
|
||||||
? 'Skip and Launch'
|
? 'Relaunch team'
|
||||||
: 'Launch'
|
: 'Launch team'
|
||||||
: isEditing
|
: isEditing
|
||||||
? 'Save Changes'
|
? 'Save Changes'
|
||||||
: 'Create Schedule';
|
: 'Create Schedule';
|
||||||
|
|
||||||
const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...';
|
const submittingLabel = isLaunchMode
|
||||||
|
? isRelaunch
|
||||||
|
? 'Relaunching...'
|
||||||
|
: 'Launching...'
|
||||||
|
: isEditing
|
||||||
|
? 'Saving...'
|
||||||
|
: 'Creating...';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Render
|
// Render
|
||||||
|
|
@ -1531,15 +1580,37 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
|
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
|
||||||
<DialogDescription className="text-xs">{dialogDescription}</DialogDescription>
|
<DialogDescription className="text-xs">{dialogDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</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 */}
|
{/* Launch-only: Conflict warning */}
|
||||||
{isLaunch && conflictingTeam && !conflictDismissed ? (
|
{isLaunchMode && conflictingTeam && !conflictDismissed ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-md border p-3 text-xs"
|
className="rounded-md border p-3 text-xs"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1714,10 +1785,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
Launch: optional settings
|
Launch: optional settings
|
||||||
Schedule: prompt + execution defaults
|
Schedule: prompt + execution defaults
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════════ */}
|
||||||
{isLaunch ? (
|
{isLaunchMode ? (
|
||||||
<OptionalSettingsSection
|
<OptionalSettingsSection
|
||||||
title="Optional launch settings"
|
title={isRelaunch ? 'Relaunch settings' : 'Optional launch settings'}
|
||||||
description="Keep the launch flow focused on the project path and only expand this when you want extra control."
|
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}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -1964,9 +2039,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 */}
|
{/* Launch-only: CLI warm-up status */}
|
||||||
{isLaunch ? (
|
{isLaunchMode ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1980,7 +2055,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
: 'Preparing environment...')}
|
: 'Preparing environment...')}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2023,13 +2101,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
CLI environment is not available - launch is blocked
|
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
|
||||||
|
blocked
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-red-300/80">
|
<p className="mt-0.5 text-red-300/80">
|
||||||
{prepareMessage ?? 'Failed to prepare environment'}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2080,7 +2159,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={closeDialog}>
|
<Button variant="outline" size="sm" onClick={closeDialog}>
|
||||||
{isLaunch ? 'Close' : 'Cancel'}
|
{isLaunchMode ? 'Close' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { Label } from '@renderer/components/ui/label';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Check, FolderOpen } from 'lucide-react';
|
import { Check, FolderOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
import { buildProjectPathOptions } from './projectPathOptions';
|
||||||
|
|
||||||
import type { Project } from '@shared/types';
|
import type { Project } from '@shared/types';
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
|
|
@ -69,131 +71,134 @@ export const ProjectPathSelector = ({
|
||||||
projectsLoading,
|
projectsLoading,
|
||||||
projectsError,
|
projectsError,
|
||||||
fieldError,
|
fieldError,
|
||||||
}: ProjectPathSelectorProps): React.JSX.Element => (
|
}: ProjectPathSelectorProps): React.JSX.Element => {
|
||||||
<div className="space-y-1.5">
|
const projectOptions = React.useMemo(
|
||||||
<Label>Project</Label>
|
() => buildProjectPathOptions(projects, selectedProjectPath),
|
||||||
<div className="space-y-2">
|
[projects, selectedProjectPath]
|
||||||
<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">
|
return (
|
||||||
{cwdMode === 'project' ? (
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<Label>Project</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
<div className="flex flex-col gap-2 md:flex-row md:items-start">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||||
<Combobox
|
<button
|
||||||
options={projects.map((project) => ({
|
type="button"
|
||||||
value: project.path,
|
className={cn(
|
||||||
label: project.name,
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
description: project.path,
|
cwdMode === 'project'
|
||||||
}))}
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
value={selectedProjectPath}
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
onValueChange={onSelectedProjectPathChange}
|
)}
|
||||||
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
onClick={() => onCwdModeChange('project')}
|
||||||
searchPlaceholder="Search project by name or path"
|
>
|
||||||
emptyMessage="Nothing found"
|
From project list
|
||||||
disabled={projectsLoading || projects.length === 0}
|
</button>
|
||||||
renderOption={(option, isSelected, query) => (
|
<button
|
||||||
<>
|
type="button"
|
||||||
<Check
|
className={cn(
|
||||||
className={cn(
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
'mr-2 size-3.5 shrink-0',
|
cwdMode === 'custom'
|
||||||
isSelected ? 'opacity-100' : 'opacity-0'
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
)}
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
/>
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
onClick={() => onCwdModeChange('custom')}
|
||||||
<p className="truncate font-medium text-[var(--color-text)]">
|
>
|
||||||
{renderHighlightedText(option.label, query)}
|
Custom path
|
||||||
</p>
|
</button>
|
||||||
<p className="truncate text-[var(--color-text-muted)]">
|
</div>
|
||||||
{renderHighlightedText(option.description ?? '', query)}
|
|
||||||
</p>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
{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>
|
</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>
|
</div>
|
||||||
{!selectedProjectPath ? (
|
) : (
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<div className="space-y-1.5">
|
||||||
Select a project from the list
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
) : null}
|
<Input
|
||||||
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
className="h-8 flex-1 text-xs"
|
||||||
{!projectsLoading && projects.length === 0 ? (
|
value={customCwd}
|
||||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
aria-label="Custom working directory"
|
||||||
No projects found, switch to custom path.
|
onChange={(event) => onCustomCwdChange(event.target.value)}
|
||||||
</p>
|
placeholder="/absolute/path/to/project"
|
||||||
) : null}
|
/>
|
||||||
</div>
|
<Button
|
||||||
) : (
|
variant="outline"
|
||||||
<div className="space-y-1.5">
|
size="sm"
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => {
|
||||||
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
void (async () => {
|
||||||
<Input
|
try {
|
||||||
className="h-8 flex-1 text-xs"
|
const paths = await api.config.selectFolders();
|
||||||
value={customCwd}
|
if (paths.length > 0) {
|
||||||
aria-label="Custom working directory"
|
onCustomCwdChange(paths[0]);
|
||||||
onChange={(event) => onCustomCwdChange(event.target.value)}
|
}
|
||||||
placeholder="/absolute/path/to/project"
|
} catch {
|
||||||
/>
|
// IPC error - dialog may have been cancelled or failed
|
||||||
<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
|
}}
|
||||||
}
|
>
|
||||||
})();
|
Browse
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
Browse
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
</Button>
|
If the directory does not exist, it will be created automatically.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
)}
|
||||||
If the directory does not exist, it will be created automatically.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{fieldError ? (
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
|
{fieldError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{fieldError ? (
|
);
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
};
|
||||||
{fieldError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ interface SendMessageDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky action mode — survives dialog close/reopen (component remount)
|
// Sticky action mode within the current session.
|
||||||
// Default: 'delegate' for teams (overridden to 'do' if solo/no teammates)
|
// Each dialog open still re-derives the default from the current team shape.
|
||||||
let stickyActionMode: ActionMode = 'delegate';
|
let stickyActionMode: ActionMode = 'delegate';
|
||||||
|
|
||||||
export const SendMessageDialog = ({
|
export const SendMessageDialog = ({
|
||||||
|
|
@ -168,7 +168,12 @@ export const SendMessageDialog = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !prevOpenRef.current) {
|
if (open && !prevOpenRef.current) {
|
||||||
const leadName = members.find((m) => isLeadMember(m))?.name;
|
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);
|
setQuote(quotedMessage);
|
||||||
setQuoteExpanded(false);
|
setQuoteExpanded(false);
|
||||||
prevResultRef.current = lastResult;
|
prevResultRef.current = lastResult;
|
||||||
|
|
@ -188,6 +193,8 @@ export const SendMessageDialog = ({
|
||||||
defaultChip,
|
defaultChip,
|
||||||
quotedMessage,
|
quotedMessage,
|
||||||
lastResult,
|
lastResult,
|
||||||
|
members,
|
||||||
|
setActionMode,
|
||||||
textDraft,
|
textDraft,
|
||||||
chipDraft,
|
chipDraft,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
|
|
@ -149,6 +150,7 @@ export const TaskDetailDialog = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||||
|
|
@ -697,7 +699,10 @@ export const TaskDetailDialog = ({
|
||||||
style={reviewerBadgeStyle}
|
style={reviewerBadgeStyle}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(currentTask.reviewer, 18)}
|
src={
|
||||||
|
avatarMap.get(currentTask.reviewer) ??
|
||||||
|
agentAvatarUrl(currentTask.reviewer, 18)
|
||||||
|
}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
165
src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
45
src/renderer/components/team/dialogs/projectPathOptions.ts
Normal 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;
|
||||||
|
}
|
||||||
30
src/renderer/components/team/dialogs/teamRelaunchFlow.ts
Normal 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);
|
||||||
|
}
|
||||||
132
src/renderer/components/team/members/LeadModelRow.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,8 +13,9 @@ import { Label } from '@renderer/components/ui/label';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
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 { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
|
|
@ -54,7 +55,7 @@ export const LeadModelRow = ({
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
|
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
|
||||||
const modelButtonLabel = model.trim()
|
const modelButtonLabel = model.trim()
|
||||||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||||
: 'Default';
|
: 'Default';
|
||||||
|
|
@ -63,7 +64,7 @@ export const LeadModelRow = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: isLight
|
backgroundColor: isLight
|
||||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||||
|
|
@ -77,9 +78,17 @@ export const LeadModelRow = ({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex h-8 items-center gap-3 px-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--color-text)]">lead</span>
|
<img
|
||||||
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
|
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>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -48,6 +53,26 @@ interface MemberCardProps {
|
||||||
onAssignTask?: () => void;
|
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 = ({
|
export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
|
|
@ -77,6 +102,11 @@ export const MemberCard = ({
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// 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({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -102,6 +132,8 @@ export const MemberCard = ({
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
|
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||||
|
splitRuntimeSummaryMemory(runtimeSummary);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -151,7 +183,7 @@ export const MemberCard = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name)}
|
||||||
alt={member.name}
|
alt={member.name}
|
||||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -215,13 +247,19 @@ export const MemberCard = ({
|
||||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)]">
|
<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}
|
{runtimeSummaryText ? (
|
||||||
{runtimeSummary && roleLabel ? (
|
<span className="min-w-0 truncate">{runtimeSummaryText}</span>
|
||||||
|
) : null}
|
||||||
|
{runtimeSummaryText && roleLabel ? (
|
||||||
<span className="shrink-0 opacity-60">•</span>
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
) : null}
|
) : null}
|
||||||
{roleLabel ? <span className="shrink-0">{roleLabel}</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ export const MemberDetailDialog = ({
|
||||||
: undefined,
|
: undefined,
|
||||||
[launchParams, member, runtimeEntry, spawnEntry]
|
[launchParams, member, runtimeEntry, spawnEntry]
|
||||||
);
|
);
|
||||||
|
const restartInFlight =
|
||||||
|
spawnEntry?.launchState === 'starting' ||
|
||||||
|
spawnEntry?.launchState === 'runtime_pending_bootstrap';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -267,7 +270,7 @@ export const MemberDetailDialog = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
disabled={restarting}
|
disabled={restarting || restartInFlight}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRestartError(null);
|
setRestartError(null);
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -52,6 +55,11 @@ export const MemberDetailHeader = ({
|
||||||
updatingRole,
|
updatingRole,
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
const [editing, setEditing] = useState(false);
|
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
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
|
|
@ -84,7 +92,7 @@ export const MemberDetailHeader = ({
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name, 96)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 96)}
|
||||||
alt={member.name}
|
alt={member.name}
|
||||||
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||||
import {
|
import {
|
||||||
|
formatTeamModelSummary,
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
TeamModelSelector,
|
TeamModelSelector,
|
||||||
|
|
@ -29,6 +30,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
interface MemberDraftRowProps {
|
interface MemberDraftRowProps {
|
||||||
member: MemberDraft;
|
member: MemberDraft;
|
||||||
index: number;
|
index: number;
|
||||||
|
avatarSrc?: string;
|
||||||
resolvedColor?: string;
|
resolvedColor?: string;
|
||||||
nameError: string | null;
|
nameError: string | null;
|
||||||
onNameChange: (id: string, name: string) => void;
|
onNameChange: (id: string, name: string) => void;
|
||||||
|
|
@ -50,18 +52,30 @@ interface MemberDraftRowProps {
|
||||||
taskSuggestions?: MentionSuggestion[];
|
taskSuggestions?: MentionSuggestion[];
|
||||||
teamSuggestions?: MentionSuggestion[];
|
teamSuggestions?: MentionSuggestion[];
|
||||||
lockProviderModel?: boolean;
|
lockProviderModel?: boolean;
|
||||||
|
lockRole?: boolean;
|
||||||
|
lockedRoleLabel?: string;
|
||||||
|
lockIdentity?: boolean;
|
||||||
|
identityLockReason?: string;
|
||||||
forceInheritedModelSettings?: boolean;
|
forceInheritedModelSettings?: boolean;
|
||||||
modelLockReason?: string;
|
modelLockReason?: string;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
|
hideActionButton?: boolean;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
modelIssueText?: string | null;
|
modelIssueText?: string | null;
|
||||||
|
lockedModelAction?: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
member,
|
member,
|
||||||
index,
|
index,
|
||||||
|
avatarSrc,
|
||||||
resolvedColor,
|
resolvedColor,
|
||||||
nameError,
|
nameError,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
|
@ -83,17 +97,24 @@ export const MemberDraftRow = ({
|
||||||
taskSuggestions,
|
taskSuggestions,
|
||||||
teamSuggestions,
|
teamSuggestions,
|
||||||
lockProviderModel = false,
|
lockProviderModel = false,
|
||||||
|
lockRole = false,
|
||||||
|
lockedRoleLabel,
|
||||||
|
lockIdentity = false,
|
||||||
|
identityLockReason,
|
||||||
forceInheritedModelSettings = false,
|
forceInheritedModelSettings = false,
|
||||||
modelLockReason,
|
modelLockReason,
|
||||||
isRemoved = false,
|
isRemoved = false,
|
||||||
onRestore,
|
onRestore,
|
||||||
|
hideActionButton = false,
|
||||||
warningText,
|
warningText,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueText,
|
modelIssueText,
|
||||||
|
lockedModelAction,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
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 [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
|
|
@ -175,10 +196,16 @@ export const MemberDraftRow = ({
|
||||||
? `${modelButtonLabelBase} (lead)`
|
? `${modelButtonLabelBase} (lead)`
|
||||||
: modelButtonLabelBase;
|
: modelButtonLabelBase;
|
||||||
const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
|
const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
|
||||||
|
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
|
||||||
const modelTooltipText = forceInheritedModelSettings
|
const modelTooltipText = forceInheritedModelSettings
|
||||||
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
|
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
|
||||||
: modelLockReason;
|
: (lockedModelAction?.description ?? modelLockReason);
|
||||||
const hasModelIssue = Boolean(modelIssueText);
|
const hasModelIssue = Boolean(modelIssueText);
|
||||||
|
const runtimeSummary = formatTeamModelSummary(
|
||||||
|
effectiveProviderId,
|
||||||
|
effectiveModel?.trim() ?? '',
|
||||||
|
effectiveEffort
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -196,33 +223,42 @@ export const MemberDraftRow = ({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
className="h-8 text-xs"
|
{avatarSrc ? (
|
||||||
value={member.name}
|
<img
|
||||||
aria-label={`Member ${index + 1} name`}
|
src={avatarSrc}
|
||||||
disabled={isRemoved}
|
alt=""
|
||||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
className="size-8 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
placeholder="member-name"
|
loading="lazy"
|
||||||
style={
|
/>
|
||||||
member.name.trim()
|
) : null}
|
||||||
? {
|
<Input
|
||||||
color: memberColorSet.text,
|
className="h-8 text-xs"
|
||||||
}
|
value={member.name}
|
||||||
: undefined
|
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}
|
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<RoleSelect
|
{lockRole ? (
|
||||||
value={member.roleSelection || '__none__'}
|
<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">
|
||||||
disabled={isRemoved}
|
{lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
|
||||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
</div>
|
||||||
customRole={member.customRole}
|
) : (
|
||||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
<RoleSelect
|
||||||
triggerClassName="h-8 text-xs"
|
value={member.roleSelection || '__none__'}
|
||||||
inputClassName="h-8 text-xs"
|
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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
<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'
|
'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}
|
aria-label={modelButtonAriaLabel}
|
||||||
disabled={lockProviderModel || isRemoved}
|
disabled={(lockProviderModel && !canOpenLockedModelPanel) || isRemoved}
|
||||||
onClick={() => setModelExpanded((prev) => !prev)}
|
onClick={() => setModelExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{modelExpanded ? (
|
{modelExpanded ? (
|
||||||
|
|
@ -289,7 +325,7 @@ export const MemberDraftRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{isRemoved ? (
|
{hideActionButton ? null : isRemoved ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -358,36 +394,66 @@ export const MemberDraftRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
{modelExpanded && (
|
{modelExpanded && (
|
||||||
<div className="space-y-2 pl-3 md:col-span-3">
|
<div className="space-y-2 pl-3 md:col-span-3">
|
||||||
<TeamModelSelector
|
{lockProviderModel && lockedModelAction ? (
|
||||||
providerId={effectiveProviderId}
|
<div className="space-y-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
onProviderChange={(providerId) => {
|
<div className="space-y-1">
|
||||||
if (lockProviderModel) return;
|
<p className="text-[11px] font-medium text-[var(--color-text)]">
|
||||||
onProviderChange(member.id, providerId);
|
Current lead runtime
|
||||||
}}
|
</p>
|
||||||
value={effectiveModel ?? ''}
|
<p className="text-[11px] text-[var(--color-text-muted)]">{runtimeSummary}</p>
|
||||||
onValueChange={(value) => {
|
</div>
|
||||||
if (lockProviderModel) return;
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
onModelChange(member.id, value);
|
{lockedModelAction.description ??
|
||||||
}}
|
'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.'}
|
||||||
id={`member-${member.id}-model`}
|
</p>
|
||||||
disableGeminiOption={disableGeminiOption}
|
<p className="text-[11px] text-amber-300">
|
||||||
modelIssueReasonByValue={
|
Saving those runtime changes restarts the whole team.
|
||||||
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
|
</p>
|
||||||
}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
<EffortLevelSelector
|
variant="secondary"
|
||||||
value={effectiveEffort ?? ''}
|
size="sm"
|
||||||
onValueChange={(value) => {
|
className="w-fit"
|
||||||
if (lockProviderModel) return;
|
onClick={lockedModelAction.onClick}
|
||||||
onEffortChange(member.id, value);
|
disabled={lockedModelAction.disabled}
|
||||||
}}
|
>
|
||||||
id={`member-${member.id}-effort`}
|
{lockedModelAction.label}
|
||||||
/>
|
</Button>
|
||||||
{lockProviderModel && (
|
</div>
|
||||||
<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.'}
|
<TeamModelSelector
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -92,6 +93,7 @@ export const MemberHoverCard = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||||
|
const avatarMap = buildMemberAvatarMap(teamMembers);
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
@ -142,7 +144,7 @@ export const MemberHoverCard = ({
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name, 64)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
|
||||||
alt={member.name}
|
alt={member.name}
|
||||||
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
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 { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
@ -88,10 +89,15 @@ export interface MembersEditorSectionProps {
|
||||||
hideContent?: boolean;
|
hideContent?: boolean;
|
||||||
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
/** 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 }[];
|
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. */
|
/** Default provider to use for newly added member rows. */
|
||||||
defaultProviderId?: TeamProviderId;
|
defaultProviderId?: TeamProviderId;
|
||||||
/** When true, provider/model controls stay read-only for existing rows. */
|
/** When true, provider/model controls stay read-only for existing rows. */
|
||||||
lockProviderModel?: boolean;
|
lockProviderModel?: boolean;
|
||||||
|
/** When true, existing teammate names stay read-only while the team is live. */
|
||||||
|
lockExistingMemberIdentity?: boolean;
|
||||||
|
identityLockReason?: string;
|
||||||
inheritedProviderId?: TeamProviderId;
|
inheritedProviderId?: TeamProviderId;
|
||||||
inheritedModel?: string;
|
inheritedModel?: string;
|
||||||
inheritedEffort?: EffortLevel;
|
inheritedEffort?: EffortLevel;
|
||||||
|
|
@ -102,6 +108,8 @@ export interface MembersEditorSectionProps {
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||||
|
disableAddMember?: boolean;
|
||||||
|
addMemberLockReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MembersEditorSection = ({
|
export const MembersEditorSection = ({
|
||||||
|
|
@ -118,8 +126,11 @@ export const MembersEditorSection = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
hideContent = false,
|
hideContent = false,
|
||||||
existingMembers,
|
existingMembers,
|
||||||
|
existingMemberColorMap,
|
||||||
defaultProviderId = 'anthropic',
|
defaultProviderId = 'anthropic',
|
||||||
lockProviderModel = false,
|
lockProviderModel = false,
|
||||||
|
lockExistingMemberIdentity = false,
|
||||||
|
identityLockReason,
|
||||||
inheritedProviderId,
|
inheritedProviderId,
|
||||||
inheritedModel,
|
inheritedModel,
|
||||||
inheritedEffort,
|
inheritedEffort,
|
||||||
|
|
@ -130,6 +141,8 @@ export const MembersEditorSection = ({
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
memberModelIssueById,
|
memberModelIssueById,
|
||||||
|
disableAddMember = false,
|
||||||
|
addMemberLockReason,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
|
@ -257,8 +270,8 @@ export const MembersEditorSection = ({
|
||||||
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||||
const hasDuplicates = new Set(names).size !== names.length;
|
const hasDuplicates = new Set(names).size !== names.length;
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => buildMemberDraftColorMap(members, existingMembers),
|
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
|
||||||
[members, existingMembers]
|
[members, existingMembers, existingMemberColorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo(
|
const mentionSuggestions = useMemo(
|
||||||
|
|
@ -272,7 +285,14 @@ export const MembersEditorSection = ({
|
||||||
<Label>Members</Label>
|
<Label>Members</Label>
|
||||||
{!hideContent && (
|
{!hideContent && (
|
||||||
<div className="flex gap-2">
|
<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" />
|
<Plus className="size-3.5" />
|
||||||
Add member
|
Add member
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -287,13 +307,17 @@ export const MembersEditorSection = ({
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
{!hideContent && (
|
{!hideContent && (
|
||||||
<>
|
<>
|
||||||
|
{disableAddMember && addMemberLockReason ? (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeMembers.map((member, index) => (
|
{activeMembers.map((member, index) => (
|
||||||
<MemberDraftRow
|
<MemberDraftRow
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
index={index}
|
index={index}
|
||||||
resolvedColor={memberColorMap.get(member.name.trim())}
|
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
|
||||||
|
resolvedColor={memberColorMap.get(member.id)}
|
||||||
nameError={validateMemberName?.(member.name) ?? null}
|
nameError={validateMemberName?.(member.name) ?? null}
|
||||||
onNameChange={updateMemberName}
|
onNameChange={updateMemberName}
|
||||||
onRoleChange={updateMemberRole}
|
onRoleChange={updateMemberRole}
|
||||||
|
|
@ -315,6 +339,8 @@ export const MembersEditorSection = ({
|
||||||
taskSuggestions={taskSuggestions}
|
taskSuggestions={taskSuggestions}
|
||||||
teamSuggestions={teamSuggestions}
|
teamSuggestions={teamSuggestions}
|
||||||
lockProviderModel={lockProviderModel}
|
lockProviderModel={lockProviderModel}
|
||||||
|
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
|
||||||
|
identityLockReason={identityLockReason}
|
||||||
modelLockReason={modelLockReason}
|
modelLockReason={modelLockReason}
|
||||||
warningText={memberWarningById?.[member.id] ?? null}
|
warningText={memberWarningById?.[member.id] ?? null}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
|
@ -332,7 +358,8 @@ export const MembersEditorSection = ({
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
index={activeMembers.length + index}
|
index={activeMembers.length + index}
|
||||||
resolvedColor={memberColorMap.get(member.name.trim())}
|
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
|
||||||
|
resolvedColor={memberColorMap.get(member.id)}
|
||||||
nameError={null}
|
nameError={null}
|
||||||
onNameChange={updateMemberName}
|
onNameChange={updateMemberName}
|
||||||
onRoleChange={updateMemberRole}
|
onRoleChange={updateMemberRole}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
export interface MemberDraft {
|
export interface MemberDraft {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
originalName?: string;
|
||||||
roleSelection: string;
|
roleSelection: string;
|
||||||
customRole: string;
|
customRole: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,20 @@
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
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 { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
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 {
|
export function validateMemberNameInline(name: string): string | null {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (!isValidMemberName(trimmed)) {
|
return validateTeamMemberNameFormat(trimmed);
|
||||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newDraftId(): string {
|
function newDraftId(): string {
|
||||||
|
|
@ -36,6 +27,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
return {
|
return {
|
||||||
id: initial?.id ?? newDraftId(),
|
id: initial?.id ?? newDraftId(),
|
||||||
name: initial?.name ?? '',
|
name: initial?.name ?? '',
|
||||||
|
originalName: initial?.originalName,
|
||||||
roleSelection: initial?.roleSelection ?? '',
|
roleSelection: initial?.roleSelection ?? '',
|
||||||
customRole: initial?.customRole ?? '',
|
customRole: initial?.customRole ?? '',
|
||||||
workflow: initial?.workflow,
|
workflow: initial?.workflow,
|
||||||
|
|
@ -66,6 +58,7 @@ export function createMemberDraftsFromInputs(
|
||||||
const isPreset = presetRoles.includes(role);
|
const isPreset = presetRoles.includes(role);
|
||||||
return createMemberDraft({
|
return createMemberDraft({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
originalName: member.name,
|
||||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||||
customRole: role && !isPreset ? role : '',
|
customRole: role && !isPreset ? role : '',
|
||||||
workflow: member.workflow,
|
workflow: member.workflow,
|
||||||
|
|
@ -139,52 +132,62 @@ interface ExistingMemberColorInput {
|
||||||
removedAt?: number | string | null;
|
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(
|
export function buildMemberDraftColorMap(
|
||||||
members: readonly Pick<MemberDraft, 'name'>[],
|
members: readonly Pick<MemberDraft, 'id' | 'name' | 'originalName'>[],
|
||||||
existingMembers?: readonly ExistingMemberColorInput[]
|
existingMembers?: readonly ExistingMemberColorInput[],
|
||||||
|
existingColorMap?: ReadonlyMap<string, string>
|
||||||
): Map<string, string> {
|
): Map<string, string> {
|
||||||
const draftEntries = members
|
const normalizedExistingColorMap = new Map<string, string>(
|
||||||
.map((member) => member.name.trim())
|
Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [
|
||||||
.filter(Boolean)
|
name.trim().toLowerCase(),
|
||||||
.map((name) => ({ name }));
|
color,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
const existingSeedEntries = (existingMembers ?? [])
|
const existingSeedEntries = (existingMembers ?? [])
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
name: member.name.trim(),
|
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);
|
.filter((member) => member.name);
|
||||||
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
|
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
|
||||||
const unseenNewDraftNames = new Set<string>();
|
const uniqueNewDraftEntries = members
|
||||||
const uniqueNewDraftEntries = draftEntries.filter((entry) => {
|
.filter((member) => {
|
||||||
const normalizedName = entry.name.toLowerCase();
|
if (member.originalName?.trim()) {
|
||||||
if (existingNames.has(normalizedName) || unseenNewDraftNames.has(normalizedName)) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
const currentName = member.name.trim();
|
||||||
unseenNewDraftNames.add(normalizedName);
|
return !currentName || !existingNames.has(currentName.toLowerCase());
|
||||||
return true;
|
})
|
||||||
|
.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(
|
const fullColorByName = new Map(
|
||||||
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
|
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
|
||||||
);
|
);
|
||||||
|
|
||||||
const draftMap = new Map<string, string>();
|
const draftMap = new Map<string, string>();
|
||||||
for (const entry of draftEntries) {
|
for (const member of members) {
|
||||||
const color = fullColorByName.get(entry.name.toLowerCase());
|
const originalName = member.originalName?.trim();
|
||||||
if (color) draftMap.set(entry.name, color);
|
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;
|
return draftMap;
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +212,7 @@ export function buildMemberDraftSuggestions(
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
subtitle: getMemberDraftRole(m),
|
subtitle: getMemberDraftRole(m),
|
||||||
color: colorMap.get(m.name.trim()) ?? undefined,
|
color: colorMap.get(m.id) ?? undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,9 @@ export const MessageComposer = ({
|
||||||
if (!isInitializedRef.current) {
|
if (!isInitializedRef.current) {
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
||||||
|
if (shouldAutoDelegate && actionMode === 'do') {
|
||||||
|
setActionMode('delegate');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Sheet, type SheetRef } from 'react-modal-sheet';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
|
@ -27,7 +36,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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 { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
||||||
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
||||||
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
||||||
|
|
@ -91,6 +100,67 @@ interface MessagesPanelProps {
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
/** Callback when a task ID link is clicked. */
|
/** Callback when a task ID link is clicked. */
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
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({
|
export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
@ -113,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
|
inlineScrollContainerRef,
|
||||||
}: MessagesPanelProps): React.JSX.Element {
|
}: MessagesPanelProps): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
sendTeamMessage,
|
sendTeamMessage,
|
||||||
|
|
@ -125,6 +196,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
messages,
|
messages,
|
||||||
messagesState,
|
messagesState,
|
||||||
loadOlderTeamMessages,
|
loadOlderTeamMessages,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
sendTeamMessage: s.sendTeamMessage,
|
sendTeamMessage: s.sendTeamMessage,
|
||||||
|
|
@ -137,8 +209,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
messages: selectTeamMessages(s, teamName),
|
messages: selectTeamMessages(s, teamName),
|
||||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||||
|
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const bootstrapHeadRefreshAttemptedForTeamRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const loadOlderMessages = useCallback(async () => {
|
const loadOlderMessages = useCallback(async () => {
|
||||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||||
|
|
@ -157,6 +231,36 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const bottomSheetRef = useRef<SheetRef>(null);
|
const bottomSheetRef = useRef<SheetRef>(null);
|
||||||
const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(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(() => {
|
const handleExpandContent = useCallback(() => {
|
||||||
// no-op: user is reading expanded content, not composing
|
// no-op: user is reading expanded content, not composing
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -224,6 +328,41 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
bottomSheetSnapIndex,
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (position !== 'sidebar') return;
|
if (position !== 'sidebar') return;
|
||||||
const el = sidebarScrollRef.current;
|
const el = sidebarScrollRef.current;
|
||||||
|
|
@ -352,20 +491,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
// Auto-clear pending replies when a member actually responds
|
// Auto-clear pending replies when a member actually responds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
||||||
const next = { ...pendingRepliesByMember };
|
const next = reconcilePendingRepliesByMember(pendingRepliesByMember, replyCandidateMessages);
|
||||||
let changed = false;
|
if (next !== pendingRepliesByMember) onPendingReplyChange(() => next);
|
||||||
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);
|
|
||||||
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
|
|
@ -581,6 +708,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
@ -766,6 +894,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
@ -987,6 +1116,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
<Sheet.Content
|
<Sheet.Content
|
||||||
className="min-h-0 bg-[var(--color-surface-sidebar)]"
|
className="min-h-0 bg-[var(--color-surface-sidebar)]"
|
||||||
scrollClassName="flex min-h-full flex-col"
|
scrollClassName="flex min-h-full flex-col"
|
||||||
|
scrollRef={bottomSheetScrollRef}
|
||||||
disableDrag={(state) => state.scrollPosition !== 'top'}
|
disableDrag={(state) => state.scrollPosition !== 'top'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1052,6 +1182,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface TeamClaudeLogsSidebarUiState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesStateByTeam = new Map<string, TeamMessagesSidebarUiState>();
|
const messagesStateByTeam = new Map<string, TeamMessagesSidebarUiState>();
|
||||||
|
const pendingRepliesStateByTeam = new Map<string, Record<string, number>>();
|
||||||
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
|
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
|
||||||
|
|
||||||
function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState {
|
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 {
|
export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState {
|
||||||
const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState();
|
const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState();
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
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 { Command as CommandPrimitive } from 'cmdk';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -43,6 +47,7 @@ export const MemberSelect = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
const avatarMap = React.useMemo(() => buildMemberAvatarMap(members), [members]);
|
||||||
const selectedMember = React.useMemo(
|
const selectedMember = React.useMemo(
|
||||||
() => (value ? members.find((m) => m.name === value) : null),
|
() => (value ? members.find((m) => m.name === value) : null),
|
||||||
[members, value]
|
[members, value]
|
||||||
|
|
@ -60,7 +65,7 @@ export const MemberSelect = ({
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<img
|
<img
|
||||||
src={agentAvatarUrl(member.name, avatarSize)}
|
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
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)]"
|
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
|
<img
|
||||||
src={agentAvatarUrl(m.name, avatarSize)}
|
src={avatarMap.get(m.name) ?? agentAvatarUrl(m.name, avatarSize)}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,83 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
||||||
text: '#60a5fa',
|
text: '#60a5fa',
|
||||||
textLight: '#2563eb',
|
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: {
|
green: {
|
||||||
border: '#22c55e',
|
border: '#22c55e',
|
||||||
badge: 'rgba(34, 197, 94, 0.15)',
|
badge: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
|
|
||||||
|
|
@ -116,15 +116,7 @@ function noteTeamChangeEventBurst(teamName: string, eventType: string, visible:
|
||||||
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
|
now - diagnostic.lastWarnAt >= TEAM_CHANGE_EVENT_WARN_THROTTLE_MS
|
||||||
) {
|
) {
|
||||||
diagnostic.lastWarnAt = now;
|
diagnostic.lastWarnAt = now;
|
||||||
const typeSummary = Object.entries(diagnostic.countsByType)
|
// Disabled - this warning is too noisy during normal inbox bursts on active teams.
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([type, count]) => `${type}:${count}`)
|
|
||||||
.join(',');
|
|
||||||
logger.warn(
|
|
||||||
`[perf] team-change burst team=${teamName} total=${diagnostic.count} windowMs=${
|
|
||||||
now - diagnostic.windowStartedAt
|
|
||||||
} types=${typeSummary}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
teamChangeEventDiagnostics.set(teamName, diagnostic);
|
teamChangeEventDiagnostics.set(teamName, diagnostic);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@ import type { Session, SessionSortMode } from '@renderer/types/data';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:session');
|
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.
|
* 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;
|
const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
|
||||||
projectRefreshGeneration.set(projectId, generation);
|
projectRefreshGeneration.set(projectId, generation);
|
||||||
|
|
||||||
try {
|
const fetchPage = async () => {
|
||||||
const { connectionMode } = get();
|
const { connectionMode } = get();
|
||||||
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
return api.getSessionsPaginated(projectId, null, 20, {
|
||||||
includeTotalCount: false,
|
includeTotalCount: false,
|
||||||
prefilterAll: false,
|
prefilterAll: false,
|
||||||
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResult = (result: Awaited<ReturnType<typeof api.getSessionsPaginated>>) => {
|
||||||
// Drop stale responses from older in-flight refreshes
|
// Drop stale responses from older in-flight refreshes
|
||||||
if (projectRefreshGeneration.get(projectId) !== generation) {
|
if (projectRefreshGeneration.get(projectId) !== generation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sessions without loading state
|
|
||||||
set({
|
set({
|
||||||
sessions: result.sessions,
|
sessions: result.sessions,
|
||||||
sessionsCursor: result.nextCursor,
|
sessionsCursor: result.nextCursor,
|
||||||
|
|
@ -278,7 +289,27 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
sessionsTotalCount: result.totalCount,
|
sessionsTotalCount: result.totalCount,
|
||||||
// Don't touch sessionsLoading - keep it as-is
|
// Don't touch sessionsLoading - keep it as-is
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchPage();
|
||||||
|
applyResult(result);
|
||||||
} catch (error) {
|
} 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);
|
logger.error('refreshSessionsInPlace error:', error);
|
||||||
// Don't set error state - this is a background refresh
|
// Don't set error state - this is a background refresh
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4180,11 +4180,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
restartMember: async (teamName: string, memberName: string) => {
|
restartMember: async (teamName: string, memberName: string) => {
|
||||||
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
|
try {
|
||||||
await Promise.all([
|
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
|
||||||
get().fetchMemberSpawnStatuses(teamName),
|
} finally {
|
||||||
get().fetchTeamAgentRuntime(teamName),
|
await Promise.allSettled([
|
||||||
]);
|
get().fetchMemberSpawnStatuses(teamName),
|
||||||
|
get().fetchTeamAgentRuntime(teamName),
|
||||||
|
]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeMember: async (teamName: string, memberName: string) => {
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
|
|
|
||||||
38
src/renderer/utils/memberAvatarCatalog.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import {
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
getMemberColorByName,
|
|
||||||
MEMBER_COLOR_PALETTE,
|
|
||||||
normalizeMemberColorName,
|
|
||||||
} from '@shared/constants/memberColors';
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantAvatarUrlByIndex,
|
||||||
|
LEAD_PARTICIPANT_AVATAR_URL,
|
||||||
|
PARTICIPANT_AVATAR_URLS,
|
||||||
|
} from './memberAvatarCatalog';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
|
|
@ -27,8 +29,26 @@ export function displayMemberName(name: string): string {
|
||||||
return name === 'team-lead' ? 'lead' : name;
|
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 {
|
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> = {
|
export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
|
||||||
|
|
@ -571,6 +591,12 @@ interface MemberColorInput {
|
||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MemberAvatarInput {
|
||||||
|
name: string;
|
||||||
|
removedAt?: number | string | null;
|
||||||
|
agentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a consistent name→colorName map for all members.
|
* Build a consistent name→colorName map for all members.
|
||||||
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
|
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
|
||||||
|
|
@ -579,44 +605,52 @@ interface MemberColorInput {
|
||||||
* Maps "user" to a reserved color.
|
* Maps "user" to a reserved color.
|
||||||
*/
|
*/
|
||||||
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
|
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 map = new Map<string, string>();
|
||||||
const active = members.filter((m) => !m.removedAt);
|
const activeMembers = members.filter((member) => !member.removedAt);
|
||||||
const removed = members.filter((m) => m.removedAt);
|
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
|
||||||
const usedColors = new Set<string>();
|
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
|
||||||
let nextPaletteIdx = 0;
|
|
||||||
|
|
||||||
for (const member of active) {
|
for (const [index, member] of leadMembers.entries()) {
|
||||||
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
|
map.set(member.name, index === 0 ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name));
|
||||||
if (!color || usedColors.has(color)) {
|
}
|
||||||
// Assign the next unused color from the pre-ordered palette.
|
|
||||||
while (
|
for (const [index, member] of teammateMembers.entries()) {
|
||||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
|
map.set(
|
||||||
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
|
member.name,
|
||||||
) {
|
getParticipantAvatarUrlByIndex(1 + (index % (PARTICIPANT_AVATAR_URLS.length - 1)))
|
||||||
nextPaletteIdx++;
|
);
|
||||||
}
|
}
|
||||||
color =
|
|
||||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
|
for (const member of members) {
|
||||||
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
|
if (!map.has(member.name)) {
|
||||||
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
|
map.set(
|
||||||
nextPaletteIdx++;
|
member.name,
|
||||||
|
isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
map.set(member.name, color);
|
|
||||||
usedColors.add(color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const member of removed) {
|
map.set('user', agentAvatarUrl('user'));
|
||||||
const color = member.color
|
map.set('system', agentAvatarUrl('system'));
|
||||||
? normalizeMemberColorName(member.color)
|
|
||||||
: getMemberColorByName(member.name);
|
|
||||||
map.set(member.name, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
map.set('user', 'user');
|
|
||||||
|
|
||||||
return map;
|
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<
|
export const KANBAN_COLUMN_DISPLAY: Record<
|
||||||
'review' | 'approved',
|
'review' | 'approved',
|
||||||
{ label: string; bg: string; text: string }
|
{ label: string; bg: string; text: string }
|
||||||
|
|
|
||||||
|
|
@ -6,36 +6,40 @@
|
||||||
* Intentionally excludes purple-family tones.
|
* Intentionally excludes purple-family tones.
|
||||||
*/
|
*/
|
||||||
export const MEMBER_COLOR_PALETTE = [
|
export const MEMBER_COLOR_PALETTE = [
|
||||||
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
|
// ── First 12: intentionally distinct visual families for roster readability ──
|
||||||
'blue', // 0°
|
'blue',
|
||||||
'saffron', // 177°
|
'saffron',
|
||||||
'turquoise', // 268°
|
'turquoise',
|
||||||
'brick', // 85°
|
'brick',
|
||||||
'apricot', // 131°
|
'indigo',
|
||||||
'indigo', // 314°
|
'forest',
|
||||||
'forest', // 223°
|
'apricot',
|
||||||
'pink', // 39°
|
'rose',
|
||||||
'crimson', // 59°
|
'cerulean',
|
||||||
'tangerine', // 105°
|
'olive',
|
||||||
|
'copper',
|
||||||
|
'steel',
|
||||||
|
|
||||||
// ── Next 14: still good separation ──
|
// ── Next 12: secondary accents after the core distinct set ──
|
||||||
'gold', // 151°
|
'gold',
|
||||||
'emerald', // 203°
|
'emerald',
|
||||||
'cerulean', // 288°
|
'cobalt',
|
||||||
'denim', // 334°
|
'crimson',
|
||||||
'cyan', // 20°
|
'tangerine',
|
||||||
'sage', // 242°
|
'denim',
|
||||||
'tomato', // 72°
|
'cyan',
|
||||||
'rust', // 118°
|
'sage',
|
||||||
'mustard', // 164°
|
'tomato',
|
||||||
'canary', // 190°
|
'rust',
|
||||||
'teal', // 255°
|
'mustard',
|
||||||
'arctic', // 301°
|
'canary',
|
||||||
'royal', // 347°
|
'teal',
|
||||||
'green', // 7°
|
'arctic',
|
||||||
|
'royal',
|
||||||
|
|
||||||
// ── Remaining: fill the hue gaps progressively ──
|
// ── Remaining: fill the hue gaps progressively ──
|
||||||
'rose', // 46°
|
'green',
|
||||||
|
'pink',
|
||||||
'ruby', // 92°
|
'ruby', // 92°
|
||||||
'sienna', // 144°
|
'sienna', // 144°
|
||||||
'mint', // 216°
|
'mint', // 216°
|
||||||
|
|
@ -49,90 +53,45 @@ export const MEMBER_COLOR_PALETTE = [
|
||||||
'salmon', // 79°
|
'salmon', // 79°
|
||||||
'amber', // 98°
|
'amber', // 98°
|
||||||
'peach', // 111°
|
'peach', // 111°
|
||||||
'copper', // 124°
|
|
||||||
'bronze', // 137°
|
'bronze', // 137°
|
||||||
'lemon', // 157°
|
'lemon', // 157°
|
||||||
'honey', // 170°
|
'honey', // 170°
|
||||||
'marigold', // 183°
|
'marigold', // 183°
|
||||||
'sunflower', // 196°
|
'sunflower', // 196°
|
||||||
'lime', // 209°
|
'lime', // 209°
|
||||||
'olive', // 229°
|
|
||||||
'jade', // 236°
|
'jade', // 236°
|
||||||
'chartreuse', // 249°
|
'chartreuse', // 249°
|
||||||
'aqua', // 262°
|
'aqua', // 262°
|
||||||
'azure', // 281°
|
'azure', // 281°
|
||||||
'seafoam', // 295°
|
'seafoam', // 295°
|
||||||
'cobalt', // 308°
|
|
||||||
'periwinkle', // 327°
|
'periwinkle', // 327°
|
||||||
'steel', // 340°
|
|
||||||
'cornflower', // 353°
|
'cornflower', // 353°
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed hue angle (0-359) for each palette color name.
|
* Canonical runtime/member id for the team lead.
|
||||||
* This is independent of array order — colors keep their visual identity
|
* UI surfaces should use this exact key when deriving the lead color so
|
||||||
* regardless of how MEMBER_COLOR_PALETTE is sorted.
|
* previews match the resolved team roster.
|
||||||
* Spread evenly across 360° so every name has a unique hue.
|
|
||||||
*/
|
*/
|
||||||
export const MEMBER_COLOR_HUE: Record<string, number> = {
|
export const TEAM_LEAD_MEMBER_COLOR_ID = 'team-lead' as const;
|
||||||
blue: 0,
|
|
||||||
green: 7,
|
/**
|
||||||
yellow: 13,
|
* Fixed hue angle (0-359) for each palette color name.
|
||||||
cyan: 20,
|
* The first roster-assigned colors are intentionally spaced far apart so the
|
||||||
red: 26,
|
* first 10-12 teammates in a team remain visually distinct.
|
||||||
orange: 33,
|
*/
|
||||||
pink: 39,
|
const MEMBER_COLOR_HUES_BY_ORDER = [
|
||||||
rose: 46,
|
240, 60, 180, 0, 120, 300, 30, 210, 330, 90, 150, 270, 15, 195, 105, 285, 45, 225, 135, 315, 75,
|
||||||
coral: 52,
|
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,
|
||||||
crimson: 59,
|
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,
|
||||||
scarlet: 65,
|
101.25, 281.25, 41.25, 221.25, 131.25,
|
||||||
tomato: 72,
|
] as const;
|
||||||
salmon: 79,
|
|
||||||
brick: 85,
|
export const MEMBER_COLOR_HUE: Record<string, number> = Object.fromEntries(
|
||||||
ruby: 92,
|
MEMBER_COLOR_PALETTE.map((colorName, index) => [colorName, MEMBER_COLOR_HUES_BY_ORDER[index]])
|
||||||
amber: 98,
|
) as Record<string, number>;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISALLOWED_MEMBER_COLORS = new Set([
|
const DISALLOWED_MEMBER_COLORS = new Set([
|
||||||
'purple',
|
'purple',
|
||||||
|
|
|
||||||
107
src/shared/utils/teamMemberColors.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,21 @@ export function parseNumericSuffixName(name: string): { base: string; suffix: nu
|
||||||
return { base: match[1], suffix };
|
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
|
* 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
|
* (e.g. "alice" → "alice-2"). We treat "-2+" as an auto-suffix only when the base
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ export type AgentToolDuplicateSkipReason = 'already_running' | 'bootstrap_pendin
|
||||||
|
|
||||||
export interface ParsedAgentToolResultStatus {
|
export interface ParsedAgentToolResultStatus {
|
||||||
status: 'duplicate_skipped';
|
status: 'duplicate_skipped';
|
||||||
reason: AgentToolDuplicateSkipReason;
|
reason?: AgentToolDuplicateSkipReason;
|
||||||
|
rawReason?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -262,14 +263,14 @@ export function parseAgentToolResultStatus(content: unknown): ParsedAgentToolRes
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = fields.get('reason');
|
const rawReason = fields.get('reason');
|
||||||
if (reason !== 'already_running' && reason !== 'bootstrap_pending') {
|
const reason =
|
||||||
return null;
|
rawReason === 'already_running' || rawReason === 'bootstrap_pending' ? rawReason : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'duplicate_skipped',
|
status: 'duplicate_skipped',
|
||||||
reason,
|
reason,
|
||||||
|
...(rawReason ? { rawReason } : {}),
|
||||||
name: fields.get('name'),
|
name: fields.get('name'),
|
||||||
teamName: fields.get('team_name'),
|
teamName: fields.get('team_name'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ describe('ipc teams handlers', () => {
|
||||||
deleteTeam: vi.fn(async () => undefined),
|
deleteTeam: vi.fn(async () => undefined),
|
||||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||||
|
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
||||||
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
||||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
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', () => {
|
describe('removeMember', () => {
|
||||||
it('calls service on valid input', async () => {
|
it('calls service on valid input', async () => {
|
||||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
|
|
||||||
133
test/main/services/team/TeamDataService.stallMonitor.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -461,6 +461,195 @@ function buildResolvedMember(name: string): ResolvedTeamMember {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TeamDataService', () => {
|
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 () => {
|
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasks: TeamTask[] = [
|
const tasks: TeamTask[] = [
|
||||||
|
|
|
||||||
|
|
@ -116,4 +116,38 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await tracker.disableTracking('demo', 'task_log_stream');
|
await tracker.disableTracking('demo', 'task_log_stream');
|
||||||
await tracker.disableTracking('demo', 'tool_activity');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
103
test/main/services/team/TeamMessageFeedService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -293,6 +293,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(message).toContain('team_name="forge-labs", name="alice"');
|
expect(message).toContain('team_name="forge-labs", name="alice"');
|
||||||
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
|
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('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 () => {
|
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||||
|
|
|
||||||
127
test/main/services/team/stallMonitor/ActiveTeamRegistry.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
460
test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
test/main/services/team/stallMonitor/featureGates.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,8 +13,13 @@ vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
||||||
ENTRY_REVEAL_ANIMATION_MS: 220,
|
ENTRY_REVEAL_ANIMATION_MS: 220,
|
||||||
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) =>
|
AnimatedHeightReveal: ({
|
||||||
React.createElement(React.Fragment, null, children),
|
children,
|
||||||
|
containerRef,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}) => React.createElement('div', { ref: containerRef }, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
|
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
|
||||||
|
|
@ -284,4 +289,304 @@ describe('ActivityTimeline session separators', () => {
|
||||||
root.unmount();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
1210
test/renderer/components/team/dialogs/EditTeamDialog.test.ts
Normal file
430
test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||