From 212cd37d3f231220f449a0733585ef8da4633341 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 17:40:13 +0300 Subject: [PATCH] feat(team): improve runtime lane presence state --- .../buildMixedPersistedLaunchSnapshot.ts | 29 +- .../createTeamRuntimeLaneCoordinator.test.ts | 45 +++ src/main/ipc/teams.ts | 2 + .../services/team/TeamLaunchStateEvaluator.ts | 24 +- .../team/TeamMemberWorktreeManager.ts | 19 +- .../services/team/TeamProvisioningService.ts | 122 +++++++- .../OpenCodeRuntimeManifestEvidenceReader.ts | 71 +++++ .../opencode/store/RuntimeStoreManifest.ts | 51 ++++ .../stream/OpenCodeTaskLogStreamSource.ts | 9 +- .../team/dialogs/LaunchTeamDialog.tsx | 50 ++++ .../team/dialogs/TaskDetailDialog.tsx | 157 ++++++---- .../team/members/CurrentTaskIndicator.tsx | 4 +- .../components/team/members/MemberCard.tsx | 64 ++-- .../team/members/MemberDetailHeader.tsx | 6 +- .../team/members/MemberDraftRow.tsx | 10 + .../team/members/MemberHoverCard.tsx | 6 +- .../team/members/MemberPresenceDot.tsx | 25 ++ .../team/members/MembersEditorSection.tsx | 3 + .../team/members/TeamRosterEditorSection.tsx | 3 + src/renderer/components/ui/SyncedLoader2.tsx | 28 ++ src/renderer/hooks/useSyncedAnimationStyle.ts | 29 ++ src/renderer/utils/memberHelpers.ts | 2 +- src/shared/utils/contentSanitizer.ts | 12 +- .../definitely-missing-team/inboxes/user.json | 11 - ...nCodeRuntimeManifestEvidenceReader.test.ts | 97 ++++++ .../team/OpenCodeTaskLogStreamSource.test.ts | 64 ++++ .../team/TeamMemberWorktreeManager.test.ts | 58 +++- .../team/TeamProvisioningService.test.ts | 277 ++++++++++++++++++ .../team/dialogs/LaunchTeamDialog.test.ts | 104 ++++++- .../team/dialogs/TaskDetailDialog.test.ts | 202 ++++++++++++- .../team/members/CurrentTaskIndicator.test.ts | 26 ++ .../team/members/MemberCard.test.ts | 99 ++++++- .../team/members/MemberPresenceDot.test.tsx | 65 ++++ test/renderer/utils/memberHelpers.test.ts | 12 + test/shared/utils/contentSanitizer.test.ts | 22 ++ 35 files changed, 1626 insertions(+), 182 deletions(-) create mode 100644 src/renderer/components/team/members/MemberPresenceDot.tsx create mode 100644 src/renderer/components/ui/SyncedLoader2.tsx create mode 100644 src/renderer/hooks/useSyncedAnimationStyle.ts delete mode 100644 teams/definitely-missing-team/inboxes/user.json create mode 100644 test/renderer/components/team/members/MemberPresenceDot.test.tsx diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index bb429c5d..ffbe971d 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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)] diff --git a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts index 7ddbc914..a5875ba9 100644 --- a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts +++ b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts @@ -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' + ); + }); }); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 02176f9a..a73dd279 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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, diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index a6f65031..9131b462 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -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, diff --git a/src/main/services/team/TeamMemberWorktreeManager.ts b/src/main/services/team/TeamMemberWorktreeManager.ts index 849a0d2e..0637382d 100644 --- a/src/main/services/team/TeamMemberWorktreeManager.ts +++ b/src/main/services/team/TeamMemberWorktreeManager.ts @@ -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 { 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 }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f4a2b031..0e02a44d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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, 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[]).map((member) => { + const nextMembers = (config.members as Record[]).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 { + const name = member.name.trim(); + const configMember: Record = { + 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) { diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index ea3b8c2d..c5c28e51 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -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 { + 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 { + 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; diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 17f562f7..99e6174e 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -274,6 +274,57 @@ export class RuntimeStoreManifestStore { return readStoreDataOrThrow(this.store); } + async setActiveRun(input: { + runId: string | null; + capabilitySnapshotId?: string | null; + behaviorFingerprint?: string | null; + }): Promise { + 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 { await this.store.updateLocked((manifest) => ({ ...manifest, diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index da34b3c5..ce4205c6 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -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); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index aec4b90b..c2181c9d 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -306,6 +306,26 @@ function deriveTeammateWorktreeDefault( ); } +function buildWorktreePathByMemberName( + members: readonly { + name: string; + isolation?: 'worktree'; + cwd?: string; + removedAt?: number | string | null; + }[] +): Record { + const paths: Record = {}; + 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(null); + const [worktreePathByMemberName, setWorktreePathByMemberName] = useState>( + {} + ); 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 = {}; + 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 diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index a73d8b7e..9ecda341 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 => { - 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 => { 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 = ({ {/* Changes */} - {variant === 'team' && canShowTaskChanges && onViewChanges ? ( + {variant === 'team' && canShowTaskChanges ? ( } - badge={taskChangesFiles ? taskChangesFiles.length : undefined} + badge={ + !taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined + } headerExtra={ - changesSectionOpen ? ( + taskChangesLoading && !changesSectionOpen ? ( + + ) : changesSectionOpen ? ( + {onViewChanges ? ( + + ) : ( + + {file.relativePath} + + )} {file.linesAdded > 0 ? ( +{file.linesAdded} @@ -1211,21 +1240,23 @@ export const TaskDetailDialog = ({ ) : null} - - - - - Review diff - + {onViewChanges ? ( + + + + + Review diff + + ) : null} {onOpenInEditor ? ( diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index d168359a..7bd3f765 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -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 (
- + {activityLabel}
- +
{displayMemberName(member.name)} - {member.gitBranch ? ( + {member.gitBranch && !showWorkspaceBadge ? ( {member.gitBranch} ) : null} {showWorkspaceBadge ? ( - - worktree - + + + + worktree + + + +
+ {workspaceTooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+
+
) : null} {currentTask ? ( ) : ( - )} @@ -436,8 +438,8 @@ export const MemberCard = ({ className="flex shrink-0 items-center gap-1" title={runtimeEntry?.runtimeDiagnostic} > - {skippingLaunch ? ( - + ) : ( )} @@ -503,7 +505,7 @@ export const MemberCard = ({ onClick={handleRetryFailedLaunch} > {retryingLaunch ? ( - + ) : ( )} @@ -545,7 +547,7 @@ export const MemberCard = ({ onClick={handleRetryFailedLaunch} > {retryingLaunch ? ( - + ) : ( )} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index a26a2766..37500f5a 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -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" /> - +
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index e6c9fd3f..d6e5fe9e 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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 = ({
) : null} + {!isRemoved && infoText ? ( +
+
+ +

{infoText}

+
+
+ ) : null} {showWorkflow && onWorkflowChange && workflowExpanded ? (
diff --git a/src/renderer/components/team/members/MemberPresenceDot.tsx b/src/renderer/components/team/members/MemberPresenceDot.tsx new file mode 100644 index 00000000..00978751 --- /dev/null +++ b/src/renderer/components/team/members/MemberPresenceDot.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 6624d788..3db86d26 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -111,6 +111,7 @@ export interface MembersEditorSectionProps { modelLockReason?: string; softDeleteMembers?: boolean; memberWarningById?: Record; + memberInfoById?: Record; disableGeminiOption?: boolean; memberModelIssueById?: Record; 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} /> diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index ff748ee0..1ce5b61a 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps { softDeleteMembers?: boolean; leadWarningText?: string | null; memberWarningById?: Record; + memberInfoById?: Record; disableGeminiOption?: boolean; leadModelIssueText?: string | null; memberModelIssueById?: Record; @@ -88,6 +89,7 @@ export const TeamRosterEditorSection = ({ softDeleteMembers = false, leadWarningText, memberWarningById, + memberInfoById, disableGeminiOption = false, leadModelIssueText, memberModelIssueById, @@ -148,6 +150,7 @@ export const TeamRosterEditorSection = ({
} memberWarningById={memberWarningById} + memberInfoById={memberInfoById} /> ); }; diff --git a/src/renderer/components/ui/SyncedLoader2.tsx b/src/renderer/components/ui/SyncedLoader2.tsx new file mode 100644 index 00000000..9da4f382 --- /dev/null +++ b/src/renderer/components/ui/SyncedLoader2.tsx @@ -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 & { + spinDurationMs?: number; +}; + +export function SyncedLoader2({ + className, + style, + spinDurationMs = DEFAULT_SPIN_DURATION_MS, + ...props +}: SyncedLoader2Props): React.JSX.Element { + const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs); + + return ( + + ); +} diff --git a/src/renderer/hooks/useSyncedAnimationStyle.ts b/src/renderer/hooks/useSyncedAnimationStyle.ts new file mode 100644 index 00000000..17374ed4 --- /dev/null +++ b/src/renderer/hooks/useSyncedAnimationStyle.ts @@ -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]); +} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 2af7729f..95463599 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -117,7 +117,7 @@ export const SPAWN_DOT_COLORS: Record = { 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', }; diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index 3f71e5c3..b1b28e88 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -20,6 +20,10 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/local-command-caveat>/gi, /[\s\S]*?<\/system-reminder>/gi, /[\s\S]*?<\/task-notification>/gi, + /[\s\S]*?<\/opencode_runtime_identity>/gi, + /[\s\S]*?<\/opencode_app_message_delivery>/gi, + /[\s\S]*?<\/opencode_delivery_context>/gi, + /[\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 = + /\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(); } /** diff --git a/teams/definitely-missing-team/inboxes/user.json b/teams/definitely-missing-team/inboxes/user.json deleted file mode 100644 index ef40758f..00000000 --- a/teams/definitely-missing-team/inboxes/user.json +++ /dev/null @@ -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" - } -] \ No newline at end of file diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts index 574cf63d..945c9da0 100644 --- a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -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, + }); + }); }); diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts index 2cfe6b36..98f98b45 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts @@ -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: [ + '', + '', + 'This is retry attempt 3/3 for inbound app messageId "message-1".', + '', + '', + 'New task assigned to you: #task-a Investigate failing command', + '', + ].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, diff --git a/test/main/services/team/TeamMemberWorktreeManager.test.ts b/test/main/services/team/TeamMemberWorktreeManager.test.ts index 45f8538f..5ae62587 100644 --- a/test/main/services/team/TeamMemberWorktreeManager.test.ts +++ b/test/main/services/team/TeamMemberWorktreeManager.test.ts @@ -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 { 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); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index f8ff768d..5730af8b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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) => ({ + 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( diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 21452851..139f5ef6 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -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); diff --git a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts index e3995781..6d8c4ea9 100644 --- a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts +++ b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts @@ -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(), + 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(); + 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(), + 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(), + 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(); + }); + }); }); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts index c538792f..b1744878 100644 --- a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index e6577b9a..f91c9fb7 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberPresenceDot.test.tsx b/test/renderer/components/team/members/MemberPresenceDot.test.tsx new file mode 100644 index 00000000..f17ae800 --- /dev/null +++ b/test/renderer/components/team/members/MemberPresenceDot.test.tsx @@ -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(); + }); + }); +}); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index d0506347..eba8969a 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -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', () => { diff --git a/test/shared/utils/contentSanitizer.test.ts b/test/shared/utils/contentSanitizer.test.ts index d6c71087..e793e7de 100644 --- a/test/shared/utils/contentSanitizer.test.ts +++ b/test/shared/utils/contentSanitizer.test.ts @@ -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 = [ + '', + 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send.', + '', + '', + '', + '', + 'This is retry attempt 3/3 for inbound app messageId "message-1".', + '', + '', + 'New task assigned to you: #task-a Investigate failing command', + '', + ].join('\n'); + + expect(sanitizeDisplayContent(content)).toBe( + 'New task assigned to you: #task-a Investigate failing command' + ); + }); +});