feat(team): improve runtime lane presence state

This commit is contained in:
777genius 2026-04-27 17:40:13 +03:00
parent afe50439b1
commit 212cd37d3f
35 changed files with 1626 additions and 182 deletions

View file

@ -146,6 +146,16 @@ function createPrimaryLaneMemberState(params: {
const runtime = params.status;
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
const launchState =
runtime?.launchState ??
deriveMemberLaunchState({
hardFailure: runtime?.hardFailure,
bootstrapConfirmed: runtime?.bootstrapConfirmed,
runtimeAlive: strongRuntimeAlive,
agentToolAccepted: runtime?.agentToolAccepted,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
});
const hardFailure = runtime?.hardFailure === true || launchState === 'failed_to_start';
const base: PersistedTeamLaunchMemberState = {
name: params.member.name.trim(),
providerId,
@ -173,20 +183,12 @@ function createPrimaryLaneMemberState(params: {
providerId === params.leadDefaults.providerId
? (params.leadDefaults.launchIdentity ?? undefined)
: undefined,
launchState:
runtime?.launchState ??
deriveMemberLaunchState({
hardFailure: runtime?.hardFailure,
bootstrapConfirmed: runtime?.bootstrapConfirmed,
runtimeAlive: strongRuntimeAlive,
agentToolAccepted: runtime?.agentToolAccepted,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
}),
launchState,
agentToolAccepted: runtime?.agentToolAccepted === true,
runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
hardFailure,
hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined,
@ -212,7 +214,6 @@ function createSecondaryLaneMemberState(
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const evidence = params.evidence;
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
const hardFailureReason = evidence?.hardFailureReason;
const launchState =
evidence?.launchState ??
deriveMemberLaunchState({
@ -222,6 +223,8 @@ function createSecondaryLaneMemberState(
agentToolAccepted: evidence?.agentToolAccepted,
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
});
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
const base: PersistedTeamLaunchMemberState = {
name: params.member.name.trim(),
providerId,
@ -249,7 +252,7 @@ function createSecondaryLaneMemberState(
agentToolAccepted: evidence?.agentToolAccepted === true,
runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
hardFailure,
hardFailureReason,
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
? [...new Set(evidence.pendingPermissionRequestIds)]

View file

@ -47,4 +47,49 @@ describe('createTeamRuntimeLaneCoordinator', () => {
})
).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter');
});
it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => {
const coordinator = createTeamRuntimeLaneCoordinator();
const snapshot = coordinator.buildAggregateLaunchSnapshot({
teamName: 'mixed-team',
launchPhase: 'active',
leadDefaults: {
providerId: 'codex',
},
primaryMembers: [],
primaryStatuses: {},
secondaryMembers: [
{
laneId: 'secondary:opencode:jack',
member: {
name: 'jack',
providerId: 'opencode',
model: 'qwen/qwen3-coder',
},
leadDefaults: {
providerId: 'codex',
},
evidence: {
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
hardFailureReason: 'OpenCode bridge reported member launch failure',
diagnostics: ['OpenCode runtime bootstrap check-in accepted'],
},
},
],
});
expect(snapshot.members.jack).toMatchObject({
launchState: 'confirmed_alive',
hardFailure: false,
hardFailureReason: undefined,
});
expect(snapshot.members.jack.diagnostics).not.toContain(
'hard failure reason: OpenCode bridge reported member launch failure'
);
});
});

View file

@ -4739,6 +4739,8 @@ async function handleGetSavedRequest(
name: m.name,
role: m.role,
workflow: m.workflow,
isolation: m.isolation,
cwd: m.cwd,
providerId: m.providerId,
model: m.model,
effort: m.effort,

View file

@ -428,6 +428,9 @@ function normalizePersistedMemberState(
bootstrapConfirmed,
livenessKind,
});
const hardFailure = skippedForLaunch
? false
: toBoolean(parsed.hardFailure) || parsed.launchState === 'failed_to_start';
const sources = normalizeSources(parsed.sources) ?? {};
if (!runtimeAlive) {
sources.processAlive = undefined;
@ -467,8 +470,8 @@ function normalizePersistedMemberState(
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
runtimeAlive,
bootstrapConfirmed,
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
hardFailureReason: skippedForLaunch
hardFailure,
hardFailureReason: !hardFailure
? undefined
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
? parsed.hardFailureReason.trim()
@ -629,23 +632,22 @@ export function snapshotFromRuntimeMemberStatuses(params: {
if (runtime?.livenessSource === 'process' && runtimeAlive) {
sources.processAlive = true;
}
const launchState = runtime?.launchState ?? 'starting';
const hardFailure =
runtime?.launchState === 'skipped_for_launch'
? false
: runtime?.hardFailure === true || launchState === 'failed_to_start';
const entry: PersistedTeamLaunchMemberState = {
name,
launchState: runtime?.launchState ?? 'starting',
launchState,
skippedForLaunch,
skipReason: runtime?.skipReason,
skippedAt: runtime?.skippedAt,
agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true,
runtimeAlive,
bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true,
hardFailure:
runtime?.launchState === 'skipped_for_launch'
? false
: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason:
runtime?.launchState === 'skipped_for_launch'
? undefined
: (runtime?.hardFailureReason ?? runtime?.error),
hardFailure,
hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined,

View file

@ -1,4 +1,4 @@
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import { execFile } from 'child_process';
import * as fs from 'fs';
@ -101,10 +101,18 @@ export class TeamMemberWorktreeManager {
): Promise<TeamMemberWorktreeResolution> {
const baseRepoPath = await this.resolveBaseRepoPath(request.baseCwd);
const repoHash = shortHash(baseRepoPath);
const projectSlug = slugify(path.basename(baseRepoPath));
const teamSlug = slugify(request.teamName);
const memberSlug = slugify(request.memberName);
const branchName = `agent-teams/${teamSlug}/${memberSlug}-${repoHash}`;
const worktreePath = path.join(
getAppDataPath(),
'team-worktrees',
`${projectSlug}-${repoHash}`,
teamSlug,
memberSlug
);
const legacyWorktreePath = path.join(
getClaudeBasePath(),
'team-worktrees',
repoHash,
@ -121,6 +129,15 @@ export class TeamMemberWorktreeManager {
return { baseRepoPath, worktreePath, branchName };
}
const legacyStat = await fs.promises.stat(legacyWorktreePath).catch(() => null);
if (legacyStat) {
if (!legacyStat.isDirectory()) {
throw new Error(`Worktree path exists but is not a directory: ${legacyWorktreePath}`);
}
await this.assertExistingWorktreeMatchesRepo(legacyWorktreePath, baseRepoPath, branchName);
return { baseRepoPath, worktreePath: legacyWorktreePath, branchName };
}
await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true });
await this.createWorktree({ baseRepoPath, worktreePath, branchName });
return { baseRepoPath, worktreePath, branchName };

View file

@ -159,6 +159,7 @@ import {
readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry,
removeOpenCodeRuntimeLaneIndexEntry,
setOpenCodeRuntimeActiveRunManifest,
upsertOpenCodeRuntimeLaneIndexEntry,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import {
@ -1596,6 +1597,10 @@ function isConfigRegistrationFailureReason(reason?: string): boolean {
);
}
function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean {
return reason?.trim() === 'OpenCode bridge reported member launch failure';
}
function isTmuxNoServerRunningError(error: unknown): boolean {
const text = error instanceof Error ? error.message : String(error ?? '');
return (
@ -1608,7 +1613,8 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
return (
isNeverSpawnedDuringLaunchReason(reason) ||
isLaunchGraceWindowFailureReason(reason) ||
isConfigRegistrationFailureReason(reason)
isConfigRegistrationFailureReason(reason) ||
isOpenCodeBridgeLaunchFailureReason(reason)
);
}
@ -4621,7 +4627,41 @@ export class TeamProvisioningService {
});
}
const hasTaskRefs = (input.taskRefs ?? []).length > 0;
return hasTaskRefs || input.actionMode === 'do' || input.actionMode === 'delegate';
if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') {
return false;
}
return this.hasOpenCodeNonVisibleProgressProof(input.ledgerRecord);
}
private hasOpenCodeNonVisibleProgressProof(
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
): boolean {
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
return toolNames.some((toolName) => {
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
return (
normalized === 'task_start' ||
normalized === 'task_add_comment' ||
normalized === 'task_complete' ||
normalized === 'task_set_status' ||
normalized === 'task_set_clarification' ||
normalized === 'task_create' ||
normalized === 'task_link' ||
normalized === 'runtime_task_event' ||
normalized === 'write' ||
normalized === 'edit' ||
normalized === 'patch'
);
});
}
private normalizeOpenCodeObservedToolName(toolName: string): string {
return toolName
.trim()
.replace(/^mcp__agent[-_]teams__/, '')
.replace(/^agent[-_]teams_/, '')
.replace(/^mcp__agent_teams__/, '')
.replace(/^agent_teams_/, '');
}
private isOpenCodePlainTextResponseReadCommitAllowed(input: {
@ -4673,6 +4713,9 @@ export class TeamProvisioningService {
if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') {
return 'visible_reply_still_required';
}
if (!this.hasOpenCodeNonVisibleProgressProof(record)) {
return 'non_visible_tool_without_task_progress';
}
}
if (state === 'empty_assistant_turn') {
return 'empty_assistant_turn';
@ -12178,6 +12221,12 @@ export class TeamProvisioningService {
);
try {
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: getTeamsBasePath(),
teamName: input.request.teamName,
laneId: 'primary',
runId,
});
const result = await adapter.launch(launchInput);
if (
this.cancelledRuntimeAdapterRunIds.delete(runId) ||
@ -12337,6 +12386,7 @@ export class TeamProvisioningService {
): PersistedTeamLaunchMemberState {
const now = nowIso();
const launchState = evidence?.launchState ?? 'failed_to_start';
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
return {
name: member.name,
providerId: 'opencode',
@ -12351,8 +12401,8 @@ export class TeamProvisioningService {
agentToolAccepted: evidence?.agentToolAccepted === true,
runtimeAlive: evidence?.runtimeAlive === true,
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
hardFailureReason: evidence?.hardFailureReason,
hardFailure,
hardFailureReason: hardFailure ? evidence?.hardFailureReason : undefined,
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
? [...new Set(evidence.pendingPermissionRequestIds)]
: undefined,
@ -16282,6 +16332,12 @@ export class TeamProvisioningService {
const previousLaunchState = await this.launchStateStore.read(run.teamName);
try {
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: getTeamsBasePath(),
teamName: run.teamName,
laneId: lane.laneId,
runId: lane.runId,
});
const result = await adapter.launch({
runId: lane.runId,
laneId: lane.laneId,
@ -20026,7 +20082,7 @@ export class TeamProvisioningService {
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
members: run.allEffectiveMembers,
}
);
await this.cleanupPrelaunchBackup(run.teamName);
@ -20228,7 +20284,7 @@ export class TeamProvisioningService {
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
members: run.allEffectiveMembers,
}
);
@ -21169,7 +21225,7 @@ export class TeamProvisioningService {
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
members: run.allEffectiveMembers,
}
);
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
@ -21396,6 +21452,7 @@ export class TeamProvisioningService {
}
private applyEffectiveLaunchStateToConfig(
teamName: string,
config: Record<string, unknown>,
launchState?: {
providerId?: TeamProviderId;
@ -21419,7 +21476,7 @@ export class TeamProvisioningService {
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
);
config.members = (config.members as Record<string, unknown>[]).map((member) => {
const nextMembers = (config.members as Record<string, unknown>[]).map((member) => {
if (!member || typeof member !== 'object') {
return member;
}
@ -21477,6 +21534,53 @@ export class TeamProvisioningService {
});
return nextMember;
});
const existingNames = new Set(
nextMembers
.map((member) => (typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''))
.filter(Boolean)
);
for (const member of launchState.members ?? []) {
const name = member.name?.trim();
if (!name || existingNames.has(name.toLowerCase())) {
continue;
}
const providerId = normalizeTeamMemberProviderId(member.providerId);
if (providerId !== 'opencode') {
continue;
}
nextMembers.push(this.buildOpenCodeConfigMemberFromLaunchMember(teamName, member));
existingNames.add(name.toLowerCase());
}
config.members = nextMembers;
}
private buildOpenCodeConfigMemberFromLaunchMember(
teamName: string,
member: TeamCreateRequest['members'][number]
): Record<string, unknown> {
const name = member.name.trim();
const configMember: Record<string, unknown> = {
name,
agentId: `${name}@${teamName}`,
agentType: 'general-purpose',
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
providerId: 'opencode',
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
cwd: member.cwd?.trim() || undefined,
joinedAt: Date.now(),
};
return Object.fromEntries(
Object.entries(configMember).filter(([, value]) => value !== undefined)
);
}
/**
@ -21571,7 +21675,7 @@ export class TeamProvisioningService {
: pathHistory;
}
this.applyEffectiveLaunchStateToConfig(config, launchState);
this.applyEffectiveLaunchStateToConfig(teamName, config, launchState);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
} catch (error) {

View file

@ -8,6 +8,8 @@ import { withFileLock } from '../../fileLock';
import {
createDefaultRuntimeStoreManifest,
createRuntimeStoreManifestStore,
OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
validateRuntimeStoreManifest,
} from './RuntimeStoreManifest';
@ -386,6 +388,75 @@ export async function upsertOpenCodeRuntimeLaneIndexEntry(params: {
});
}
export async function setOpenCodeRuntimeActiveRunManifest(params: {
teamsBasePath: string;
teamName: string;
laneId?: string | null;
runId: string | null;
clock?: () => Date;
}): Promise<void> {
const manifestPath = getOpenCodeRuntimeManifestPath(
params.teamsBasePath,
params.teamName,
params.laneId
);
await ensureRuntimeManifestEnvelope(
manifestPath,
params.teamName,
params.clock ?? (() => new Date())
);
const manifestStore = createRuntimeStoreManifestStore({
filePath: manifestPath,
teamName: params.teamName,
clock: params.clock,
});
await manifestStore.setActiveRun({ runId: params.runId });
}
async function ensureRuntimeManifestEnvelope(
manifestPath: string,
teamName: string,
clock: () => Date
): Promise<void> {
let raw: string;
try {
raw = await readFile(manifestPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
const parsed = JSON.parse(raw) as unknown;
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Object.prototype.hasOwnProperty.call(parsed, 'data')
) {
return;
}
const manifest = validateRuntimeStoreManifest(parsed);
await mkdir(path.dirname(manifestPath), { recursive: true });
await atomicWriteAsync(
manifestPath,
`${JSON.stringify(
{
schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
updatedAt: clock().toISOString(),
data: {
...manifest,
teamName,
},
},
null,
2
)}\n`
);
}
export async function removeOpenCodeRuntimeLaneIndexEntry(params: {
teamsBasePath: string;
teamName: string;

View file

@ -274,6 +274,57 @@ export class RuntimeStoreManifestStore {
return readStoreDataOrThrow(this.store);
}
async setActiveRun(input: {
runId: string | null;
capabilitySnapshotId?: string | null;
behaviorFingerprint?: string | null;
}): Promise<RuntimeStoreManifest> {
const normalizedRunId = input.runId?.trim() || null;
const result = await this.store.updateLocked((manifest) => {
const normalizedCapabilitySnapshotId =
input.capabilitySnapshotId === undefined
? manifest.activeCapabilitySnapshotId
: input.capabilitySnapshotId?.trim() || null;
const normalizedBehaviorFingerprint =
input.behaviorFingerprint === undefined
? manifest.activeBehaviorFingerprint
: input.behaviorFingerprint?.trim() || null;
const changed =
manifest.activeRunId !== normalizedRunId ||
manifest.activeCapabilitySnapshotId !== normalizedCapabilitySnapshotId ||
manifest.activeBehaviorFingerprint !== normalizedBehaviorFingerprint ||
this.isActiveRunOnlyWatermark(manifest);
if (!changed) {
return manifest;
}
return {
...manifest,
activeRunId: normalizedRunId,
activeCapabilitySnapshotId: normalizedCapabilitySnapshotId,
activeBehaviorFingerprint: normalizedBehaviorFingerprint,
highWatermark: this.resolveActiveRunWatermark(manifest),
updatedAt: this.clock().toISOString(),
};
});
return result.data;
}
private isActiveRunOnlyWatermark(manifest: RuntimeStoreManifest): boolean {
return (
manifest.highWatermark > 0 &&
manifest.entries.length === 0 &&
manifest.lastCommittedBatchId === null
);
}
private resolveActiveRunWatermark(manifest: RuntimeStoreManifest): number {
if (this.isActiveRunOnlyWatermark(manifest)) {
return 0;
}
return manifest.highWatermark;
}
async markBatchPreparing(batch: RuntimeStoreWriteBatch): Promise<void> {
await this.store.updateLocked((manifest) => ({
...manifest,

View file

@ -1,4 +1,5 @@
import { createLogger } from '@shared/utils/logger';
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
@ -737,8 +738,10 @@ function mapOpenCodeContentBlock(
block: OpenCodeRuntimeTranscriptLogContentBlock
): ContentBlock | null {
switch (block.type) {
case 'text':
return { type: 'text', text: block.text };
case 'text': {
const text = sanitizeDisplayContent(block.text);
return text.length > 0 ? { type: 'text', text } : null;
}
case 'thinking':
return {
type: 'thinking',
@ -795,7 +798,7 @@ function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMe
const normalizedContent: ContentBlock[] | string =
typeof message.content === 'string'
? message.content
? sanitizeDisplayContent(message.content)
: message.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null);

View file

@ -306,6 +306,26 @@ function deriveTeammateWorktreeDefault(
);
}
function buildWorktreePathByMemberName(
members: readonly {
name: string;
isolation?: 'worktree';
cwd?: string;
removedAt?: number | string | null;
}[]
): Record<string, string> {
const paths: Record<string, string> = {};
for (const member of members) {
const name = member.name.trim().toLowerCase();
const cwd = member.cwd?.trim();
if (!name || member.removedAt || member.isolation !== 'worktree' || !cwd) {
continue;
}
paths[name] = cwd;
}
return paths;
}
// =============================================================================
// Component
// =============================================================================
@ -458,6 +478,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [maxTurns, setMaxTurns] = useState(50);
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
const [scheduleHydrationKey, setScheduleHydrationKey] = useState<string | null>(null);
const [worktreePathByMemberName, setWorktreePathByMemberName] = useState<Record<string, string>>(
{}
);
const effectiveMemberDrafts = useMemo(
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
[membersDrafts, syncModelsWithLead]
@ -802,6 +825,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
)
);
setWorktreePathByMemberName(buildWorktreePathByMemberName(editableMembersSource));
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource));
setSyncModelsWithLead(
!editableMembersSource.some((member) => member.providerId || member.model || member.effort)
@ -1280,6 +1304,31 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return warnings;
}, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]);
const memberWorktreeContinuationInfoById = useMemo(() => {
if (!isLaunchMode) {
return {};
}
const info: Record<string, string> = {};
for (const member of effectiveMemberDrafts) {
if (member.removedAt || member.isolation !== 'worktree') {
continue;
}
const lookupName = (member.originalName?.trim() || member.name.trim()).toLowerCase();
if (!lookupName) {
continue;
}
const previousWorktreePath = worktreePathByMemberName[lookupName];
if (!previousWorktreePath) {
continue;
}
info[member.id] =
`This teammate will continue from its existing worktree: ${previousWorktreePath}`;
}
return info;
}, [effectiveMemberDrafts, isLaunchMode, worktreePathByMemberName]);
// ---------------------------------------------------------------------------
// Launch-only effects
// ---------------------------------------------------------------------------
@ -2451,6 +2500,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
memberWarningById={combinedMemberRuntimeWarningById}
memberInfoById={memberWorktreeContinuationInfoById}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}
softDeleteMembers

View file

@ -55,7 +55,6 @@ import {
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { canDisplayTaskChanges } from '@shared/utils/taskChangeState';
import {
deriveTaskDisplayId,
formatTaskDisplayLabel,
@ -86,6 +85,7 @@ import {
} from 'lucide-react';
const TASK_CHANGES_AUTO_REFRESH_MS = 20_000;
const TASK_CHANGES_INITIAL_LOAD_DELAY_MS = 1_500;
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
@ -325,8 +325,9 @@ export const TaskDetailDialog = ({
? currentTask.sourceMessage.attachments.length
: 0;
// Lazy-load task changes for any displayable state (in_progress, review, approved, completed).
const canShowTaskChanges = currentTask ? canDisplayTaskChanges(currentTask) : false;
// Changes is the explicit lazy-load entry point. Keep it visible for all team tasks,
// including old/pending tasks that may resolve to an empty result.
const canShowTaskChanges = Boolean(currentTask);
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
const taskChangeRequestOptions = useMemo(
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
@ -361,13 +362,7 @@ export const TaskDetailDialog = ({
const loadTaskChangeSummary = useCallback(
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
if (
!currentTask ||
!taskChangeSummaryOptions ||
variant !== 'team' ||
!canShowTaskChanges ||
!onViewChanges
) {
if (!currentTask || !taskChangeSummaryOptions || variant !== 'team' || !canShowTaskChanges) {
return null;
}
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
@ -376,7 +371,7 @@ export const TaskDetailDialog = ({
});
return data;
},
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
[canShowTaskChanges, currentTask, taskChangeSummaryOptions, teamName, variant]
);
const syncTaskChangeSummaryResult = useCallback(
@ -410,14 +405,7 @@ export const TaskDetailDialog = ({
preserveFilesOnError?: boolean;
} = {}): Promise<void> => {
const requestKey = currentTaskChangeSummaryKeyRef.current;
if (
!requestKey ||
!currentTask ||
variant !== 'team' ||
!canShowTaskChanges ||
!onViewChanges
)
return;
if (!requestKey || !currentTask || variant !== 'team' || !canShowTaskChanges) return;
if (taskChangesLoadInFlightKeysRef.current.has(requestKey)) return;
taskChangesLoadInFlightKeysRef.current.add(requestKey);
@ -449,32 +437,27 @@ export const TaskDetailDialog = ({
}
}
},
[
canShowTaskChanges,
currentTask,
loadTaskChangeSummary,
onViewChanges,
syncTaskChangeSummaryResult,
variant,
]
[canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant]
);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
return;
if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) return;
const summaryKey = currentTaskChangeSummaryKey;
if (loadedTaskChangeSummaryKeyRef.current === summaryKey) {
return;
}
if (taskChangesFiles !== null) {
loadedTaskChangeSummaryKeyRef.current = summaryKey;
return;
}
loadedTaskChangeSummaryKeyRef.current = summaryKey;
// Show full loading state only when no files are cached yet;
// otherwise let the refresh button spinner indicate background reload.
// The manual open path only reaches this branch when no summary is cached yet.
void requestTaskChangeSummary({
forceFresh: false,
showSpinner: !taskChangesFiles || taskChangesFiles.length === 0,
showSpinner: true,
preserveFilesOnError: false,
});
}, [
@ -483,7 +466,6 @@ export const TaskDetailDialog = ({
currentTask,
canShowTaskChanges,
teamName,
onViewChanges,
currentTaskChangeSummaryKey,
taskChangeRequestSignature,
variant,
@ -491,6 +473,41 @@ export const TaskDetailDialog = ({
taskChangesFiles,
]);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !canShowTaskChanges || changesSectionOpen) return;
if (!currentTaskChangeSummaryKey || taskChangesFiles !== null) return;
const summaryKey = currentTaskChangeSummaryKey;
if (loadedTaskChangeSummaryKeyRef.current === summaryKey) {
return;
}
const timer = window.setTimeout(() => {
if (currentTaskChangeSummaryKeyRef.current !== summaryKey) {
return;
}
void requestTaskChangeSummary({
forceFresh: false,
showSpinner: true,
preserveFilesOnError: true,
});
}, TASK_CHANGES_INITIAL_LOAD_DELAY_MS);
return () => {
window.clearTimeout(timer);
};
}, [
changesSectionOpen,
open,
currentTask,
canShowTaskChanges,
currentTaskChangeSummaryKey,
requestTaskChangeSummary,
taskChangesFiles,
variant,
]);
useEffect(() => {
if (!open || !changesSectionOpen) {
loadedTaskChangeSummaryKeyRef.current = null;
@ -499,7 +516,7 @@ export const TaskDetailDialog = ({
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) {
if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) {
return;
}
@ -519,7 +536,6 @@ export const TaskDetailDialog = ({
open,
currentTask,
canShowTaskChanges,
onViewChanges,
requestTaskChangeSummary,
variant,
]);
@ -1138,14 +1154,21 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection>
{/* Changes */}
{variant === 'team' && canShowTaskChanges && onViewChanges ? (
{variant === 'team' && canShowTaskChanges ? (
<CollapsibleTeamSection
key={`task-changes:${currentTask.id}`}
title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
badge={
!taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined
}
headerExtra={
changesSectionOpen ? (
taskChangesLoading && !changesSectionOpen ? (
<Loader2
size={12}
className="pointer-events-none animate-spin text-[var(--color-text-muted)]"
/>
) : changesSectionOpen ? (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -1192,16 +1215,22 @@ export const TaskDetailDialog = ({
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
className="size-3.5"
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
{file.relativePath}
</button>
{onViewChanges ? (
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
{file.relativePath}
</button>
) : (
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)]">
{file.relativePath}
</span>
)}
<span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span>
@ -1211,21 +1240,23 @@ export const TaskDetailDialog = ({
) : null}
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
{onViewChanges ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
) : null}
{onOpenInEditor ? (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -1,5 +1,5 @@
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { Loader2 } from 'lucide-react';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import type { TeamTaskWithKanban } from '@shared/types';
@ -31,7 +31,7 @@ export const CurrentTaskIndicator = ({
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"

View file

@ -6,6 +6,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
@ -21,18 +22,11 @@ import {
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
Ban,
GitBranch,
Loader2,
MessageSquare,
Plus,
RotateCcw,
} from 'lucide-react';
import { AlertTriangle, Ban, GitBranch, MessageSquare, Plus, RotateCcw } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberPresenceDot } from './MemberPresenceDot';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
@ -183,9 +177,11 @@ export const MemberCard = ({
const isLead = isLeadMember(member);
const workspacePath = member.cwd?.trim();
const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree';
const workspaceBadgeTitle = workspacePath
? `Worktree isolation configured. Worktree path: ${workspacePath}`
: 'Worktree isolation is configured, but the runtime path is not available yet';
const workspaceTooltipLines = [
'Worktree isolation is enabled.',
workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.',
member.gitBranch ? `Branch: ${member.gitBranch}` : null,
].filter((line): line is string => Boolean(line));
const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -200,7 +196,6 @@ export const MemberCard = ({
!runtimeSummary;
const showLaunchBadge =
!isRemoved &&
!activityTask &&
!runtimeAdvisoryLabel &&
(presenceLabel === 'starting' ||
presenceLabel === 'connecting' ||
@ -335,29 +330,36 @@ export const MemberCard = ({
loading="lazy"
/>
</div>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={displayPresenceLabel}
/>
<MemberPresenceDot className={`size-2.5 ${dotClass}`} label={displayPresenceLabel} />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)}
</span>
{member.gitBranch ? (
{member.gitBranch && !showWorkspaceBadge ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
{member.gitBranch}
</span>
) : null}
{showWorkspaceBadge ? (
<span
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
title={workspaceBadgeTitle}
>
worktree
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300">
worktree
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm text-xs leading-relaxed">
<div className="space-y-1">
{workspaceTooltipLines.map((line) => (
<p key={line} className="break-words">
{line}
</p>
))}
</div>
</TooltipContent>
</Tooltip>
) : null}
{currentTask ? (
<CurrentTaskIndicator
@ -380,8 +382,8 @@ export const MemberCard = ({
{runtimeAdvisoryTone === 'error' ? (
<AlertTriangle className="size-3 shrink-0 text-red-400" />
) : (
<Loader2
className={`size-3 shrink-0 animate-spin ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
<SyncedLoader2
className={`size-3 shrink-0 ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
style={runtimeAdvisoryLabel ? undefined : { color: colors.border }}
/>
)}
@ -436,8 +438,8 @@ export const MemberCard = ({
className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
>
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
<SyncedLoader2
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}
/>
<Badge
@ -480,7 +482,7 @@ export const MemberCard = ({
onClick={handleSkipFailedLaunch}
>
{skippingLaunch ? (
<Loader2 className="size-3.5 animate-spin" />
<SyncedLoader2 className="size-3.5" />
) : (
<Ban className="size-3.5" />
)}
@ -503,7 +505,7 @@ export const MemberCard = ({
onClick={handleRetryFailedLaunch}
>
{retryingLaunch ? (
<Loader2 className="size-3.5 animate-spin" />
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
@ -545,7 +547,7 @@ export const MemberCard = ({
onClick={handleRetryFailedLaunch}
>
{retryingLaunch ? (
<Loader2 className="size-3.5 animate-spin" />
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}

View file

@ -16,6 +16,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
import { MemberPresenceDot } from './MemberPresenceDot';
import type {
LeadActivityState,
@ -116,10 +117,7 @@ export const MemberDetailHeader = ({
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={badgeLabel}
/>
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="truncate" style={{ color: colors.text }}>

View file

@ -73,6 +73,7 @@ interface MemberDraftRowProps {
onRestore?: (id: string) => void;
hideActionButton?: boolean;
warningText?: string | null;
infoText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
showWorktreeIsolationControls?: boolean;
@ -122,6 +123,7 @@ export const MemberDraftRow = ({
onRestore,
hideActionButton = false,
warningText,
infoText,
disableGeminiOption = false,
modelIssueText,
showWorktreeIsolationControls = false,
@ -419,6 +421,14 @@ export const MemberDraftRow = ({
</div>
</div>
) : null}
{!isRemoved && infoText ? (
<div className="md:col-span-3">
<div className="ml-3 flex items-start gap-2 rounded-md border border-sky-400/25 bg-sky-500/10 px-3 py-2 text-[11px] leading-relaxed text-sky-100">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-300" />
<p className="min-w-0 whitespace-pre-wrap break-words">{infoText}</p>
</div>
</div>
) : null}
{showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 pl-3 md:col-span-3">
<label

View file

@ -36,6 +36,7 @@ import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provi
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberPresenceDot } from './MemberPresenceDot';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
@ -191,10 +192,7 @@ export const MemberHoverCard = ({
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={badgeLabel}
/>
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">

View file

@ -0,0 +1,25 @@
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
import { cn } from '@renderer/lib/utils';
const PULSE_DURATION_MS = 2000;
interface MemberPresenceDotProps {
className?: string;
label: string;
}
export function MemberPresenceDot({ className, label }: MemberPresenceDotProps): React.JSX.Element {
const shouldSyncPulse = className?.includes('animate-pulse') === true;
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
return (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
className
)}
style={syncedPulseStyle}
aria-label={label}
/>
);
}

View file

@ -111,6 +111,7 @@ export interface MembersEditorSectionProps {
modelLockReason?: string;
softDeleteMembers?: boolean;
memberWarningById?: Record<string, string | null | undefined>;
memberInfoById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
memberModelIssueById?: Record<string, string | null | undefined>;
disableAddMember?: boolean;
@ -149,6 +150,7 @@ export const MembersEditorSection = ({
modelLockReason,
softDeleteMembers = false,
memberWarningById,
memberInfoById,
disableGeminiOption = false,
memberModelIssueById,
disableAddMember = false,
@ -415,6 +417,7 @@ export const MembersEditorSection = ({
identityLockReason={identityLockReason}
modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
infoText={memberInfoById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}
modelIssueText={memberModelIssueById?.[member.id] ?? null}
/>

View file

@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps {
softDeleteMembers?: boolean;
leadWarningText?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
memberInfoById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
leadModelIssueText?: string | null;
memberModelIssueById?: Record<string, string | null | undefined>;
@ -88,6 +89,7 @@ export const TeamRosterEditorSection = ({
softDeleteMembers = false,
leadWarningText,
memberWarningById,
memberInfoById,
disableGeminiOption = false,
leadModelIssueText,
memberModelIssueById,
@ -148,6 +150,7 @@ export const TeamRosterEditorSection = ({
</div>
}
memberWarningById={memberWarningById}
memberInfoById={memberInfoById}
/>
);
};

View file

@ -0,0 +1,28 @@
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
import { cn } from '@renderer/lib/utils';
import { Loader2 } from 'lucide-react';
import type { ComponentProps } from 'react';
const DEFAULT_SPIN_DURATION_MS = 1000;
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
spinDurationMs?: number;
};
export function SyncedLoader2({
className,
style,
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
...props
}: SyncedLoader2Props): React.JSX.Element {
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs);
return (
<Loader2
{...props}
className={cn('animate-spin', className)}
style={{ ...syncedStyle, ...style }}
/>
);
}

View file

@ -0,0 +1,29 @@
import { useMemo } from 'react';
import type { CSSProperties } from 'react';
const DEFAULT_ANIMATION_DURATION_MS = 1000;
function getCurrentTimeMs(): number {
return typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
}
export function useSyncedAnimationStyle(
enabled: boolean,
durationMs = DEFAULT_ANIMATION_DURATION_MS
): CSSProperties | undefined {
return useMemo(() => {
if (!enabled) {
return undefined;
}
const safeDurationMs =
Number.isFinite(durationMs) && durationMs > 0 ? durationMs : DEFAULT_ANIMATION_DURATION_MS;
const phaseMs = getCurrentTimeMs() % safeDurationMs;
return {
animationDelay: `${-phaseMs}ms`,
animationDuration: `${safeDurationMs}ms`,
};
}, [durationMs, enabled]);
}

View file

@ -117,7 +117,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
offline: 'bg-zinc-600',
waiting: 'bg-zinc-400 animate-pulse',
spawning: 'bg-amber-400',
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
online: 'bg-emerald-400 animate-pulse',
error: 'bg-red-400',
skipped: 'bg-zinc-500',
};

View file

@ -20,6 +20,10 @@ const NOISE_TAG_PATTERNS = [
/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
/<system-reminder>[\s\S]*?<\/system-reminder>/gi,
/<task-notification>[\s\S]*?<\/task-notification>/gi,
/<opencode_runtime_identity>[\s\S]*?<\/opencode_runtime_identity>/gi,
/<opencode_app_message_delivery>[\s\S]*?<\/opencode_app_message_delivery>/gi,
/<opencode_delivery_context>[\s\S]*?<\/opencode_delivery_context>/gi,
/<opencode_delivery_retry>[\s\S]*?<\/opencode_delivery_retry>/gi,
];
/**
@ -27,6 +31,8 @@ const NOISE_TAG_PATTERNS = [
* task notifications.
*/
const TASK_OUTPUT_INSTRUCTION_PATTERN = / ?Read the output file to retrieve the result: [^\s]+/g;
const OPENCODE_INBOUND_APP_MESSAGE_PATTERN =
/<opencode_inbound_app_message>\s*([\s\S]*?)\s*<\/opencode_inbound_app_message>/gi;
export interface CommandOutputInfo {
stream: 'stdout' | 'stderr';
@ -121,6 +127,10 @@ export function sanitizeDisplayContent(content: string): string {
for (const pattern of NOISE_TAG_PATTERNS) {
sanitized = sanitized.replace(pattern, '');
}
sanitized = sanitized.replace(
OPENCODE_INBOUND_APP_MESSAGE_PATTERN,
(_match, innerContent: string | undefined) => innerContent?.trim() ?? ''
);
// Also remove any remaining command tags (in case of mixed content)
sanitized = sanitized
@ -131,7 +141,7 @@ export function sanitizeDisplayContent(content: string): string {
// Remove follow-up instructions that only make sense in raw XML form.
sanitized = sanitized.replace(TASK_OUTPUT_INSTRUCTION_PATTERN, '');
return sanitized.trim();
return sanitized.replace(/\n{3,}/g, '\n\n').trim();
}
/**

View file

@ -1,11 +0,0 @@
[
{
"from": "nobody",
"to": "user",
"text": "plainprobe",
"timestamp": "2026-04-23T17:45:03.432Z",
"read": false,
"summary": "plainprobe",
"messageId": "a3ed3161-c883-4a6d-aff1-bc64e5eb547f"
}
]

View file

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
OpenCodeRuntimeManifestEvidenceReader,
getOpenCodeRuntimeManifestPath,
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeLaneIndexPath,
getOpenCodeTeamRuntimeDirectory,
@ -13,8 +14,10 @@ import {
migrateLegacyOpenCodeRuntimeState,
readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry,
setOpenCodeRuntimeActiveRunManifest,
upsertOpenCodeRuntimeLaneIndexEntry,
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { createDefaultRuntimeStoreManifest } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
let tempDir: string;
@ -350,4 +353,98 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
},
});
});
it('persists lane-scoped activeRunId for runtime evidence after app restart', async () => {
const teamName = 'team-theta';
const laneId = 'secondary:opencode:jack';
const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir });
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-opencode-jack',
clock: () => now,
});
await expect(reader.read(teamName, laneId)).resolves.toMatchObject({
activeRunId: 'run-opencode-jack',
highWatermark: 0,
});
});
it('updates raw legacy runtime manifests without dropping existing capability metadata', async () => {
const teamName = 'team-iota';
const laneId = 'secondary:opencode:alice';
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
const legacyManifest = {
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
activeRunId: 'run-old',
activeCapabilitySnapshotId: 'cap-existing',
activeBehaviorFingerprint: 'behavior-existing',
highWatermark: 5,
};
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, `${JSON.stringify(legacyManifest, null, 2)}\n`, 'utf8');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
clock: () => now,
});
await expect(
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId)
).resolves.toMatchObject({
activeRunId: 'run-new',
capabilitySnapshotId: 'cap-existing',
highWatermark: 0,
});
});
it('preserves committed manifest highWatermark when persisting activeRunId', async () => {
const teamName = 'team-kappa';
const laneId = 'secondary:opencode:bob';
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
const committedManifest = {
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
activeRunId: 'run-old',
highWatermark: 5,
lastCommittedBatchId: 'batch-1',
entries: [
{
schemaName: 'opencode.launchState',
schemaVersion: 1,
relativePath: 'launch-state.json',
contentHash: 'sha256:test',
fileSize: 12,
mtimeMs: 123,
runId: 'run-old',
capabilitySnapshotId: null,
behaviorFingerprint: null,
lastWriteReceiptId: 'receipt-1',
state: 'healthy',
},
],
};
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, `${JSON.stringify(committedManifest, null, 2)}\n`, 'utf8');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
clock: () => now,
});
await expect(
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId)
).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 5,
});
});
});

View file

@ -260,6 +260,70 @@ describe('OpenCodeTaskLogStreamSource', () => {
expect(second).toEqual(first);
});
it('sanitizes OpenCode delivery retry envelopes from projected task log text', async () => {
const bridge = {
getOpenCodeTranscript: vi.fn(async () => ({
sessionId: 'session-opencode',
logProjection: {
messages: [
textLogMessage({
uuid: 'task-delivery',
type: 'user',
role: 'user',
timestamp: '2026-04-21T10:05:00.000Z',
content: [
{
type: 'text',
text: [
'<opencode_inbound_app_message>',
'<opencode_delivery_retry>',
'This is retry attempt 3/3 for inbound app messageId "message-1".',
'</opencode_delivery_retry>',
'',
'New task assigned to you: #task-a Investigate failing command',
'</opencode_inbound_app_message>',
].join('\n'),
},
],
}),
],
},
})),
};
const chunkBuilder = {
buildBundleChunks: vi.fn((messages) => [
{
id: 'chunk-sanitized',
kind: 'assistant',
messages,
},
]),
};
const source = new OpenCodeTaskLogStreamSource(
bridge as never,
{ resolve: async () => '/tmp/claude' },
{
getTasks: async () => [createTask()],
getDeletedTasks: async () => [],
} as never,
chunkBuilder as never,
{ readTaskRecords: vi.fn(async () => []) }
);
const response = await source.getTaskLogStream('team-a', 'task-a');
expect(response?.source).toBe('opencode_runtime_fallback');
const projectedMessage = chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]?.[0] as
| { content: Array<{ type: string; text?: string }> }
| undefined;
expect(projectedMessage?.content).toEqual([
{
type: 'text',
text: 'New task assigned to you: #task-a Investigate failing command',
},
]);
});
it('returns null when the task has no owner', async () => {
const source = new OpenCodeTaskLogStreamSource(
{ getOpenCodeTranscript: vi.fn() } as never,

View file

@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
claudeRoot: '',
appDataRoot: '',
}));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => hoisted.claudeRoot,
getAppDataPath: () => hoisted.appDataRoot,
}));
import { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager';
@ -43,6 +45,26 @@ function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 10);
}
function expectedWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string {
return path.join(
hoisted.appDataRoot,
'team-worktrees',
`${slugify(path.basename(repoPath))}-${shortHash(repoPath)}`,
slugify(teamName),
slugify(memberName)
);
}
function legacyWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string {
return path.join(
hoisted.claudeRoot,
'team-worktrees',
shortHash(repoPath),
slugify(teamName),
slugify(memberName)
);
}
async function createGitRepo(root: string): Promise<string> {
const repoPath = path.join(root, 'repo');
await fs.mkdir(repoPath, { recursive: true });
@ -59,7 +81,9 @@ describe('TeamMemberWorktreeManager', () => {
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-member-worktree-'));
hoisted.claudeRoot = path.join(tempRoot, 'claude');
hoisted.appDataRoot = path.join(tempRoot, 'app-data');
await fs.mkdir(hoisted.claudeRoot, { recursive: true });
await fs.mkdir(hoisted.appDataRoot, { recursive: true });
});
afterEach(async () => {
@ -78,23 +102,37 @@ describe('TeamMemberWorktreeManager', () => {
expect(resolution.baseRepoPath).toBe(repoPath);
expect(resolution.branchName).toBe(`agent-teams/atlas-hq/bob-${shortHash(repoPath)}`);
expect(resolution.worktreePath).toBe(
path.join(hoisted.claudeRoot, 'team-worktrees', shortHash(repoPath), 'atlas-hq', 'bob')
);
expect(resolution.worktreePath).toBe(expectedWorktreePath(repoPath));
expect(resolution.worktreePath.startsWith(hoisted.appDataRoot)).toBe(true);
expect(resolution.worktreePath.startsWith(hoisted.claudeRoot)).toBe(false);
await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe(
resolution.branchName
);
});
it('reuses legacy deterministic worktree paths for existing teammates', async () => {
const repoPath = await createGitRepo(tempRoot);
const manager = new TeamMemberWorktreeManager();
const branchName = `agent-teams/atlas-hq/bob-${shortHash(repoPath)}`;
const legacyPath = legacyWorktreePath(repoPath);
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await execGit(['worktree', 'add', '-b', branchName, legacyPath, 'HEAD'], repoPath);
const resolution = await manager.ensureMemberWorktree({
teamName: 'Atlas HQ',
memberName: 'Bob',
baseCwd: repoPath,
});
expect(resolution.worktreePath).toBe(legacyPath);
await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe(
branchName
);
});
it('rejects an existing deterministic path checked out on the wrong branch', async () => {
const repoPath = await createGitRepo(tempRoot);
const wrongPath = path.join(
hoisted.claudeRoot,
'team-worktrees',
shortHash(repoPath),
slugify('Atlas HQ'),
slugify('Bob')
);
const wrongPath = expectedWorktreePath(repoPath);
await fs.mkdir(path.dirname(wrongPath), { recursive: true });
await execGit(['worktree', 'add', '-b', 'some-other-branch', wrongPath, 'HEAD'], repoPath);

View file

@ -133,6 +133,7 @@ import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeManifestPath,
OpenCodeRuntimeManifestEvidenceReader,
readOpenCodeRuntimeLaneIndex,
upsertOpenCodeRuntimeLaneIndexEntry,
} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
@ -4510,6 +4511,102 @@ describe('TeamProvisioningService', () => {
expect(retryText).toContain('What did you find?');
});
it('keeps OpenCode task delivery pending after read-only non-visible tool activity', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'responded_non_visible_tool' as const,
deliveredUserMessageId: 'oc-user-task',
assistantMessageId: 'oc-assistant-read',
toolCallNames: ['read', 'bash'],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: null,
},
diagnostics: [],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
observeMessageDelivery: vi.fn(),
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Start task #task-1 now.',
messageId: 'msg-task-read-only',
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: [
{
taskId: 'task-1',
displayId: 'task-1',
teamName: 'team-a',
},
],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'retry_scheduled',
reason: 'non_visible_tool_without_task_progress',
});
});
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
const svc = new TeamProvisioningService();
const emptyResponseObservation = {
@ -5191,10 +5288,40 @@ describe('TeamProvisioningService', () => {
diagnostics: [],
},
];
const manifestPath = getOpenCodeRuntimeManifestPath(
tempTeamsBase,
teamName,
'secondary:opencode:bob'
);
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
await fsPromises.writeFile(
manifestPath,
`${JSON.stringify(
{
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
activeRunId: 'stale-run',
highWatermark: 2,
},
null,
2
)}\n`,
'utf8'
);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(async () => {
expect(adapterLaunch).toHaveBeenCalledTimes(1);
const launchInput = adapterLaunch.mock.calls[0]?.[0] as { runId?: string } | undefined;
expect(launchInput?.runId).toEqual(expect.any(String));
await expect(
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempTeamsBase }).read(
teamName,
'secondary:opencode:bob'
)
).resolves.toMatchObject({
activeRunId: launchInput?.runId,
highWatermark: 0,
});
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
lanes: {
'secondary:opencode:bob': {
@ -7683,6 +7810,112 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId);
});
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
allowConsoleLogs();
const teamName = 'mixed-opencode-post-launch-config';
const teamDir = path.join(tempTeamsBase, teamName);
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: teamName,
projectPath: '/old/project',
leadSessionId: 'old-lead-session',
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }],
},
null,
2
)}\n`,
'utf8'
);
const { svc } = createSafeLaunchService();
await (svc as any).updateConfigPostLaunch(
teamName,
tempClaudeRoot,
'new-lead-session',
undefined,
{
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'openrouter/google/gemini-2.5-flash',
},
{
name: 'jack',
role: 'Developer',
workflow: 'Work in the isolated checkout.',
providerId: 'opencode',
model: 'openrouter/qwen/qwen3-coder',
isolation: 'worktree',
cwd: jackWorktree,
},
],
}
);
const config = JSON.parse(
fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8')
) as {
leadSessionId?: string;
projectPath?: string;
members: Array<{
name: string;
agentId?: string;
agentType?: string;
providerId?: string;
model?: string;
role?: string;
workflow?: string;
isolation?: string;
cwd?: string;
}>;
};
expect(config.leadSessionId).toBe('new-lead-session');
expect(config.projectPath).toBe(tempClaudeRoot);
expect(config.members).toEqual([
expect.objectContaining({
name: 'team-lead',
providerId: 'codex',
model: 'gpt-5.4',
}),
expect.objectContaining({
name: 'bob',
agentId: `bob@${teamName}`,
agentType: 'general-purpose',
role: 'Developer',
providerId: 'opencode',
model: 'openrouter/google/gemini-2.5-flash',
}),
expect.objectContaining({
name: 'jack',
agentId: `jack@${teamName}`,
agentType: 'general-purpose',
role: 'Developer',
workflow: 'Work in the isolated checkout.',
providerId: 'opencode',
model: 'openrouter/qwen/qwen3-coder',
isolation: 'worktree',
cwd: jackWorktree,
}),
]);
expect(config.members.some((member) => member.name === 'alice')).toBe(false);
});
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
@ -9659,6 +9892,50 @@ describe('TeamProvisioningService', () => {
});
});
it('clears stale OpenCode bridge launch failure when the runtime process is verified alive', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: true,
model: 'openrouter/google/gemini-2.5-flash',
livenessKind: 'runtime_process',
providerId: 'opencode',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('12vector-room-10', {
bob: createMemberSpawnStatusEntry({
status: 'error',
launchState: 'failed_to_start',
error: 'OpenCode bridge reported member launch failure',
hardFailure: true,
hardFailureReason: 'OpenCode bridge reported member launch failure',
}),
});
expect(result.bob).toMatchObject({
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
runtimeModel: 'openrouter/google/gemini-2.5-flash',
livenessKind: 'runtime_process',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
livenessSource: 'process',
});
});
it('maps suffixed live runtime metadata keys back onto canonical spawn statuses', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(

View file

@ -7,6 +7,7 @@ const openTeamTab = vi.fn();
const fetchCliStatus = vi.fn();
const createSchedule = vi.fn();
const updateSchedule = vi.fn();
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
const storeState = {
appConfig: { general: { multimodelEnabled: true } },
@ -144,6 +145,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId?: string;
model?: string;
effort?: string;
isolation?: 'worktree';
}>
) =>
members.map((member, index) => ({
@ -153,6 +155,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
roleSelection: '',
customRole: member.role ?? '',
workflow: member.workflow ?? '',
isolation: member.isolation,
providerId: member.providerId,
model: member.model ?? '',
effort: member.effort,
@ -166,7 +169,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
}));
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
TeamRosterEditorSection: () => React.createElement('div', null, 'team-roster-editor'),
TeamRosterEditorSection: (props: any) => {
teamRosterEditorSectionMock.lastProps = props;
return React.createElement('div', null, 'team-roster-editor');
},
}));
vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({
@ -444,6 +450,7 @@ describe('LaunchTeamDialog', () => {
vi.clearAllMocks();
storeState.cliStatus = { providers: [] };
storeState.launchParamsByTeam = {};
teamRosterEditorSectionMock.lastProps = null;
});
it('renders relaunch-specific title, warning and submit label', async () => {
@ -485,6 +492,101 @@ describe('LaunchTeamDialog', () => {
});
});
it('passes existing teammate worktree path info to the roster editor', 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: 'launch',
open: true,
teamName: 'team-alpha',
members: [
{
name: 'jack',
role: 'developer',
isolation: 'worktree',
cwd: '/tmp/project/.worktrees/jack',
},
] as any,
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch: vi.fn(async () => {}),
})
);
await flush();
});
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
'draft-0':
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
});
await act(async () => {
root.unmount();
await flush();
});
});
it('preserves existing teammate worktree path info from saved launch request fallback', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
cwd: '/tmp/project',
providerId: 'codex',
model: 'gpt-5.5',
members: [
{
name: 'jack',
role: 'developer',
isolation: 'worktree',
cwd: '/tmp/project/.worktrees/jack',
providerId: 'opencode',
model: 'openrouter/qwen/qwen3-coder',
},
],
} as any);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch: vi.fn(async () => {}),
})
);
await flush();
});
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
'draft-0':
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
});
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);

View file

@ -49,11 +49,15 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
children,
defaultOpen = true,
onOpenChange,
badge,
headerExtra,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
badge?: React.ReactNode;
headerExtra?: React.ReactNode;
}) => {
const [open, setOpen] = React.useState(defaultOpen);
React.useEffect(() => {
@ -68,7 +72,13 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
type: 'button',
onClick: () => setOpen((value) => !value),
},
title
title,
badge !== undefined
? React.createElement('span', { 'data-testid': `section-badge-${title}` }, badge)
: null,
headerExtra
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
: null
),
title === 'Changes' && open ? React.createElement('div', null, children) : null
);
@ -237,7 +247,7 @@ function makeSummary(taskId: string): TaskChangeSetV2 {
function clickChangesSection(host: HTMLElement): void {
const button = [...host.querySelectorAll('button')].find(
(candidate) => candidate.textContent === 'Changes'
(candidate) => candidate.textContent?.startsWith('Changes') === true
);
if (!button) {
throw new Error('Changes section button not found');
@ -250,6 +260,7 @@ describe('TaskDetailDialog changes summary loading', () => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('does not drop a new task changes request while another task summary is still in flight', async () => {
@ -260,8 +271,8 @@ describe('TaskDetailDialog changes summary loading', () => {
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise);
const taskA = makeTask('task-a');
const taskB = makeTask('task-b');
const taskA: TeamTaskWithKanban = { ...makeTask('task-a'), changePresence: 'has_changes' };
const taskB: TeamTaskWithKanban = { ...makeTask('task-b'), changePresence: 'has_changes' };
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
@ -335,4 +346,187 @@ describe('TaskDetailDialog changes summary loading', () => {
await Promise.resolve();
});
});
it('keeps the changes section lazy-loadable when the task needs attention', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoisted.getTaskChanges.mockResolvedValueOnce({
...makeSummary('task-attention'),
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'low',
warnings: ['No file changes were recorded for this task.'],
});
const task: TeamTaskWithKanban = {
...makeTask('task-attention'),
changePresence: 'needs_attention',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
).toBe(true);
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-attention',
expect.objectContaining({ summaryOnly: true })
);
expect(host.textContent).toContain('No file changes recorded');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('preloads the changes summary after 1.5 seconds and shows header loading state', async () => {
vi.useFakeTimers();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const request = deferred<TaskChangeSetV2>();
hoisted.getTaskChanges.mockImplementationOnce(() => request.promise);
const task: TeamTaskWithKanban = { ...makeTask('task-autoload'), changePresence: 'unknown' };
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1_499);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-autoload',
expect.objectContaining({ summaryOnly: true, forceFresh: false })
);
expect(host.querySelector('[data-testid="section-badge-Changes"]')).toBeNull();
expect(
host.querySelector('[data-testid="section-extra-Changes"] .animate-spin')
).not.toBeNull();
await act(async () => {
request.resolve(makeSummary('task-autoload'));
await Promise.resolve();
});
expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe('1');
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain('src/task-autoload.ts');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the changes section visible for pending tasks and loads without a review handler', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoisted.getTaskChanges.mockResolvedValueOnce(makeSummary('task-pending'));
const task: TeamTaskWithKanban = {
...makeTask('task-pending'),
status: 'pending',
changePresence: 'unknown',
workIntervals: [],
} as unknown as TeamTaskWithKanban;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
).toBe(true);
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-pending',
expect.objectContaining({ summaryOnly: true })
);
expect(host.textContent).toContain('src/task-pending.ts');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -74,4 +74,30 @@ describe('CurrentTaskIndicator', () => {
await Promise.resolve();
});
});
it('syncs the spinner animation phase across independently mounted indicators', 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(CurrentTaskIndicator, {
task,
borderColor: '#3b82f6',
})
);
await Promise.resolve();
});
const spinner = host.querySelector('svg.animate-spin') as SVGElement | null;
expect(spinner?.style.animationDelay).toMatch(/^-?\d+(\.\d+)?ms$/);
expect(spinner?.style.animationDuration).toBe('1000ms');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('keeps runtime-pending accessibility copy honest even when launch badge is hidden by an active task', async () => {
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -254,6 +254,7 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('online');
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
@ -263,6 +264,50 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('keeps registered-only OpenCode status visible next to active task context', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
currentTaskId: currentTask.id,
},
memberColor: 'blue',
currentTask,
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'waiting',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnRuntimeAlive: false,
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: false,
providerId: 'opencode',
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-27T12:17:58.714Z',
},
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('registered');
expect(host.querySelector('[aria-label="registered"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the starting treatment and runtime summary visible while a runtime is still joining', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
@ -520,11 +565,8 @@ describe('MemberCard starting-state visuals', () => {
});
expect(host.textContent).toContain('worktree');
expect(
host.querySelector(
'[title="Worktree isolation configured. Worktree path: /tmp/project-alice-worktree"]'
)
).not.toBeNull();
expect(host.textContent).toContain('Worktree isolation is enabled.');
expect(host.textContent).toContain('Path: /tmp/project-alice-worktree');
await act(async () => {
root.render(
@ -552,13 +594,8 @@ describe('MemberCard starting-state visuals', () => {
});
expect(host.textContent).toContain('worktree');
expect(
host.querySelector(
'[title="Worktree isolation is configured, but the runtime path is not available yet"]'
)
).not.toBeNull();
expect(host.querySelector('[title="Worktree isolation configured. Runtime cwd: /tmp/project"]'))
.toBeNull();
expect(host.textContent).toContain('Path is not available yet.');
expect(host.textContent).not.toContain('Runtime cwd: /tmp/project');
await act(async () => {
root.render(
@ -1006,4 +1043,40 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
});
it('moves worktree branch details into the worktree badge tooltip', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
name: 'jack',
isolation: 'worktree',
cwd: '/Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack',
gitBranch: 'agent-teams/room/jack-abc',
},
memberColor: 'turquoise',
isTeamAlive: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('worktree');
expect(host.textContent).toContain(
'Path: /Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack'
);
expect(host.textContent).toContain('Branch: agent-teams/room/jack-abc');
expect(host.textContent?.match(/agent-teams\/room\/jack-abc/g)).toHaveLength(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,65 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemberPresenceDot } from '@renderer/components/team/members/MemberPresenceDot';
describe('MemberPresenceDot', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('uses a shared wall-clock phase for pulse animations', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
vi.spyOn(performance, 'now').mockReturnValue(725);
await act(async () => {
root.render(
React.createElement(MemberPresenceDot, {
className: 'size-2.5 bg-emerald-400 animate-pulse',
label: 'ready',
})
);
await Promise.resolve();
});
const dot = host.querySelector('span') as HTMLSpanElement | null;
expect(dot?.style.animationDelay).toBe('-725ms');
expect(dot?.style.animationDuration).toBe('2000ms');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not add animation timing to static status dots', 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(MemberPresenceDot, {
className: 'size-2.5 bg-zinc-600',
label: 'offline',
})
);
await Promise.resolve();
});
const dot = host.querySelector('span') as HTMLSpanElement | null;
expect(dot?.style.animationDelay).toBe('');
expect(dot?.style.animationDuration).toBe('');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -52,6 +52,18 @@ describe('memberHelpers spawn-aware presence', () => {
undefined
)
).toContain('bg-emerald-400');
expect(
getSpawnAwareDotClass(
member,
'online',
'runtime_pending_bootstrap',
true,
false,
true,
false,
undefined
)
).toContain('animate-pulse');
});
it('keeps accepted-but-not-yet-online teammates in starting state', () => {

View file

@ -45,3 +45,25 @@ describe('contentSanitizer task notifications', () => {
expect(parseTaskNotifications('normal user content')).toEqual([]);
});
});
describe('contentSanitizer OpenCode delivery envelopes', () => {
it('hides OpenCode delivery instructions and retry metadata while keeping inbound content', () => {
const content = [
'<opencode_app_message_delivery>',
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send.',
'</opencode_app_message_delivery>',
'',
'<opencode_inbound_app_message>',
'<opencode_delivery_retry>',
'This is retry attempt 3/3 for inbound app messageId "message-1".',
'</opencode_delivery_retry>',
'',
'New task assigned to you: #task-a Investigate failing command',
'</opencode_inbound_app_message>',
].join('\n');
expect(sanitizeDisplayContent(content)).toBe(
'New task assigned to you: #task-a Investigate failing command'
);
});
});