From f6e95f5b2fae359e0b4189e44561ad06d60c6826 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 8 May 2026 21:48:27 +0300 Subject: [PATCH] chore(team): checkpoint current frontend work --- agent-teams-controller/src/internal/review.js | 126 ++- .../src/internal/taskStore.js | 80 +- .../test/controller.test.js | 219 +++-- ...graph-four-participants-layout-preview.svg | 189 +++++ .../agent-graph-row-orbit-layout-preview.svg | 125 +++ electron.vite.config.ts | 1 + .../agent-graph/src/layout/stableSlots.ts | 791 ++++++++++++++++-- .../renderer/adapters/TeamGraphAdapter.ts | 11 +- .../renderer/ui/RecentProjectCard.tsx | 2 +- src/main/index.ts | 347 ++++++-- src/main/ipc/rendererLogs.ts | 6 + .../services/team/ClaudeBinaryResolver.ts | 63 +- .../team/ProcessBootstrapTransportEvidence.ts | 20 +- .../services/team/TeamMcpConfigBuilder.ts | 43 +- .../services/team/TeamProvisioningService.ts | 81 +- .../team/TeamTaskActivityIntervalService.ts | 269 ++++++ src/main/services/team/TeamTaskReader.ts | 21 + src/main/utils/electronUserDataMigration.ts | 61 +- src/main/utils/shellEnv.ts | 27 +- src/main/workers/team-fs-worker.ts | 29 + src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 15 + src/renderer/components/team/TeamListView.tsx | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 20 +- .../team/dialogs/LaunchTeamDialog.tsx | 54 +- .../team/dialogs/TeamModelSelector.tsx | 10 +- .../dialogs/teammateRuntimeCompatibility.tsx | 4 +- .../team/members/LeadModelRow.test.tsx | 3 - .../components/team/members/LeadModelRow.tsx | 4 - .../components/team/members/MemberCard.tsx | 31 +- .../team/members/MemberDetailDialog.tsx | 2 + .../team/members/MemberDetailHeader.tsx | 9 +- .../team/members/MemberHoverCard.tsx | 5 +- .../components/team/members/MemberList.tsx | 19 + .../team/members/membersEditorUtils.ts | 3 +- src/renderer/index.html | 172 ++++ src/renderer/main.tsx | 207 ++++- src/renderer/store/slices/teamSlice.ts | 52 +- src/renderer/utils/memberActivityTimer.ts | 62 ++ src/renderer/utils/memberHelpers.ts | 65 +- src/renderer/utils/memberLaunchDiagnostics.ts | 28 +- src/shared/types/api.ts | 29 + src/shared/types/team.ts | 13 + src/shared/utils/anthropicLaunchModel.ts | 21 +- src/shared/utils/teamGraphDefaultLayout.ts | 84 ++ .../model-gauntlet-results.json | 268 ++++++ .../model-gauntlet-results.md | 37 + test/agent-graph/stableSlots.test.ts | 271 ++++++ .../ProcessBootstrapTransportEvidence.test.ts | 22 +- .../team/TeamProvisioningService.test.ts | 2 +- .../TeamTaskActivityIntervalService.test.ts | 250 ++++++ .../utils/electronUserDataMigration.test.ts | 193 ++++- .../components/team/TeamModelSelector.test.ts | 59 +- .../TeamModelSelectorDisabledState.test.ts | 8 +- .../team/dialogs/LaunchTeamDialog.test.ts | 235 +++++- .../teammateRuntimeCompatibility.test.ts | 2 +- .../team/members/membersEditorUtils.test.ts | 4 +- test/renderer/store/teamSlice.test.ts | 78 +- .../utils/memberActivityTimer.test.ts | 36 + test/renderer/utils/memberHelpers.test.ts | 24 + .../utils/memberLaunchDiagnostics.test.ts | 28 + .../shared/utils/anthropicLaunchModel.test.ts | 25 +- .../utils/teamGraphDefaultLayout.test.ts | 59 ++ vite.web.config.ts | 1 + 64 files changed, 4620 insertions(+), 413 deletions(-) create mode 100644 docs/screenshots/agent-graph-four-participants-layout-preview.svg create mode 100644 docs/screenshots/agent-graph-row-orbit-layout-preview.svg create mode 100644 src/main/services/team/TeamTaskActivityIntervalService.ts create mode 100644 test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json create mode 100644 test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md create mode 100644 test/agent-graph/stableSlots.test.ts create mode 100644 test/main/services/team/TeamTaskActivityIntervalService.test.ts create mode 100644 test/shared/utils/teamGraphDefaultLayout.test.ts diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 1add991d..4ee960fd 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -66,6 +66,33 @@ function normalizeActorKey(value) { return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; } +function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) { + const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : ''; + if (!reviewerName) return false; + const reviewerKey = normalizeActorKey(reviewerName); + const intervals = Array.isArray(task.reviewIntervals) ? [...task.reviewIntervals] : []; + const hasOpenForReviewer = intervals.some( + (interval) => !interval.completedAt && normalizeActorKey(interval.reviewer) === reviewerKey + ); + if (hasOpenForReviewer) { + task.reviewIntervals = intervals; + return false; + } + task.reviewIntervals = [...intervals, { reviewer: reviewerName, startedAt: timestamp }]; + return true; +} + +function closeReviewIntervals(task, timestamp = new Date().toISOString()) { + if (!Array.isArray(task.reviewIntervals)) return false; + let changed = false; + task.reviewIntervals = task.reviewIntervals.map((interval) => { + if (interval.completedAt) return interval; + changed = true; + return { ...interval, completedAt: timestamp }; + }); + return changed; +} + function resolveKnownActorName(context, value, label) { const actor = typeof value === 'string' && value.trim() ? value.trim() : ''; if (!actor) return null; @@ -130,7 +157,9 @@ function getReviewStartActor(context, task, flags) { return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer'); } - throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`); + throw new Error( + `review_start requires from when task #${task.displayId || task.id} has no assigned reviewer` + ); } function getLatestReviewStartedActor(task) { @@ -155,18 +184,25 @@ function getLatestReviewStartedActor(task) { function getReviewDecisionActor(context, task, flags, actionName) { const explicit = resolveKnownActorName(context, flags.from, 'review actor'); - const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor'); - const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer'); + const startedActor = tryResolveKnownActorName( + context, + getLatestReviewStartedActor(task), + 'review actor' + ); + const assignedReviewer = tryResolveKnownActorName( + context, + getLatestReviewRequestedReviewer(task), + 'reviewer' + ); const inferredActor = startedActor && (!assignedReviewer || - resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer)) + resolveActorIdentityKey(context, startedActor) === + resolveActorIdentityKey(context, assignedReviewer)) ? startedActor : assignedReviewer; const actor = - explicit || - inferredActor || - resolveKnownActorName(context, 'team-lead', 'review actor'); + explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor'); assertMatchesAssignedReviewer(context, task, actor, actionName); return actor; } @@ -176,12 +212,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) { throw new Error(`Task #${task.displayId || task.id} is deleted`); } if (task.status !== 'completed') { - throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`); + throw new Error( + `Task #${task.displayId || task.id} must be completed before ${transitionName}` + ); } const reviewState = getEffectiveReviewState(context, task); if (reviewState !== 'review') { - throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`); + throw new Error( + `Task #${task.displayId || task.id} must be in review before ${transitionName}` + ); } return reviewState; } @@ -223,9 +263,14 @@ function startReview(context, taskId, flags = {}) { if (latestReviewEvent && latestReviewEvent.type === 'review_started') { assertReviewTransitionAllowed(context, task, 'starting review'); - const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : ''; + const existingActor = + typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : ''; const existingActorValid = existingActor - ? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true })) + ? Boolean( + runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { + allowLeadAliases: true, + }) + ) : false; const assignedReviewer = tryResolveKnownActorName( context, @@ -235,7 +280,8 @@ function startReview(context, taskId, flags = {}) { const existingMatchesAssigned = !assignedReviewer || (existingActorValid && - resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer)); + resolveActorIdentityKey(context, existingActor) === + resolveActorIdentityKey(context, assignedReviewer)); const requestedActor = typeof flags.from === 'string' && flags.from.trim() ? getReviewStartActor(context, task, flags) @@ -244,38 +290,52 @@ function startReview(context, taskId, flags = {}) { existingActorValid && existingMatchesAssigned && requestedActor && - resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor) + resolveActorIdentityKey(context, existingActor) !== + resolveActorIdentityKey(context, requestedActor) ) { - throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`); + throw new Error( + `Task #${task.displayId || task.id} review is already started by ${existingActor}` + ); } kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' }); if (!existingActorValid || !existingMatchesAssigned) { const repairedActor = requestedActor || getReviewStartActor(context, task, flags); + const timestamp = new Date().toISOString(); tasks.updateTask(context, task.id, (t) => { + openReviewInterval(t, repairedActor, timestamp); t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { type: 'review_started', from: prevReviewState, to: 'review', actor: repairedActor, + timestamp, }); t.reviewState = 'review'; return t; }); + } else { + tasks.updateTask(context, task.id, (t) => { + openReviewInterval(t, existingActor); + return t; + }); } return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; } assertReviewTransitionAllowed(context, task, 'starting review'); const from = getReviewStartActor(context, task, flags); + const timestamp = new Date().toISOString(); try { kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' }); tasks.updateTask(context, task.id, (t) => { + openReviewInterval(t, from, timestamp); t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { type: 'review_started', from: prevReviewState, to: 'review', actor: from, + timestamp, }); t.reviewState = 'review'; return t; @@ -285,7 +345,10 @@ function startReview(context, taskId, flags = {}) { try { kanban.clearKanban(context, task.id, { transition: 'rollback' }); } catch (rollbackError) { - warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError); + warnNonCritical( + `[review] rollback failed while starting review for ${task.id}`, + rollbackError + ); } throw error; } @@ -296,17 +359,23 @@ function requestReview(context, taskId, flags = {}) { const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => { const currentTask = tasks.getTask(context, taskId); if (currentTask.status !== 'completed') { - throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`); + throw new Error( + `Task #${currentTask.displayId || currentTask.id} must be completed before review` + ); } const nextFrom = resolveKnownActorName(context, flags.from, 'review requester') || resolveKnownActorName(context, 'team-lead', 'review requester'); const rawReviewer = getReviewer(context, flags); - const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null; + const nextReviewer = rawReviewer + ? resolveKnownActorName(context, rawReviewer, 'reviewer') + : null; const prevReviewState = getEffectiveReviewState(context, currentTask); if (prevReviewState === 'approved') { - throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`); + throw new Error( + `Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review` + ); } try { @@ -326,7 +395,10 @@ function requestReview(context, taskId, flags = {}) { try { kanban.clearKanban(context, currentTask.id, { transition: 'rollback' }); } catch (rollbackError) { - warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError); + warnNonCritical( + `[review] rollback failed while requesting review for ${currentTask.id}`, + rollbackError + ); } throw error; } @@ -383,7 +455,9 @@ function approveReview(context, taskId, flags = {}) { if (prevReviewState === 'approved') { if (currentTask.status !== 'completed') { - throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`); + throw new Error( + `Task #${currentTask.displayId || currentTask.id} must be completed before approval` + ); } kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' }); return { @@ -399,15 +473,18 @@ function approveReview(context, taskId, flags = {}) { } assertReviewTransitionAllowed(context, currentTask, 'approval'); + const timestamp = new Date().toISOString(); kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' }); tasks.updateTask(context, currentTask.id, (t) => { + closeReviewIntervals(t, timestamp); t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { type: 'review_approved', from: prevReviewState, to: 'approved', ...(nextNote ? { note: nextNote } : {}), actor: nextFrom, + timestamp, }); t.reviewState = 'approved'; return t; @@ -472,15 +549,22 @@ function requestChanges(context, taskId, flags = {}) { typeof flags.comment === 'string' && flags.comment.trim() ? flags.comment.trim() : 'Reviewer requested changes.'; - const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes'); + const prevReviewState = assertReviewTransitionAllowed( + context, + currentTask, + 'requesting changes' + ); + const timestamp = new Date().toISOString(); tasks.updateTask(context, currentTask.id, (t) => { + closeReviewIntervals(t, timestamp); t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { type: 'review_changes_requested', from: prevReviewState, to: 'needsFix', ...(nextComment ? { note: nextComment } : {}), actor: nextFrom, + timestamp, }); t.reviewState = 'needsFix'; return t; diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index a25b075a..f4edc682 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -66,7 +66,9 @@ function normalizeTask(rawTask, filePath) { }; if (!TASK_STATUSES.has(String(task.status || '').trim())) { - throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`); + throw new Error( + `Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}` + ); } task.status = String(task.status).trim(); @@ -121,10 +123,14 @@ function listTaskRows(paths, options = {}) { } tasks.sort((a, b) => { - const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { - numeric: true, - sensitivity: 'base', - }); + const byDisplay = String(a.displayId || a.id).localeCompare( + String(b.displayId || b.id), + undefined, + { + numeric: true, + sensitivity: 'base', + } + ); if (byDisplay !== 0) return byDisplay; return String(a.id).localeCompare(String(b.id), undefined, { numeric: true, @@ -144,7 +150,9 @@ function listTasks(paths, options = {}) { } function resolveTaskRef(paths, taskRef, options = {}) { - const normalizedRef = String(taskRef || '').trim().replace(/^#/, ''); + const normalizedRef = String(taskRef || '') + .trim() + .replace(/^#/, ''); if (!normalizedRef) { throw new Error('Missing taskId'); } @@ -168,9 +176,7 @@ function resolveTaskRef(paths, taskRef, options = {}) { } const byDisplay = tasks.find( - (task) => - task.displayId === normalizedRef && - (includeDeleted || task.status !== 'deleted') + (task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted') ); if (byDisplay) { return byDisplay.id; @@ -195,6 +201,17 @@ function appendHistoryEvent(events, event) { return list; } +function closeOpenReviewIntervals(task, timestamp) { + if (!Array.isArray(task.reviewIntervals)) return false; + let changed = false; + task.reviewIntervals = task.reviewIntervals.map((interval) => { + if (interval.completedAt) return interval; + changed = true; + return { ...interval, completedAt: timestamp }; + }); + return changed; +} + function normalizeStatus(status) { const normalized = String(status || '').trim(); return TASK_STATUSES.has(normalized) ? normalized : null; @@ -204,7 +221,10 @@ function parseRelationshipList(paths, value) { const rawValues = Array.isArray(value) ? value : typeof value === 'string' - ? value.split(',').map((entry) => entry.trim()).filter(Boolean) + ? value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) : []; return rawValues.map((entry) => resolveTaskRef(paths, entry)); @@ -248,7 +268,9 @@ function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) { ? explicitDisplayId.trim() : deriveDisplayId(canonicalId); - const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))); + const existing = new Set( + listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)) + ); if (!existing.has(preferred)) { return preferred; } @@ -310,7 +332,9 @@ function createTask(paths, input = {}) { ? input.createdBy.trim() : undefined; const createdAt = - typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso(); + typeof input.createdAt === 'string' && input.createdAt.trim() + ? input.createdAt.trim() + : nowIso(); const status = computeInitialStatus(paths, input, owner, blockedByIds); const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId); @@ -429,7 +453,10 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) { if (task.status === status) { if (status === 'deleted' || status === 'in_progress') { task.reviewState = 'none'; - } else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') { + } else if ( + status === 'pending' && + normalizeTaskReviewState(task.reviewState) !== 'needsFix' + ) { task.reviewState = 'none'; } return task; @@ -447,6 +474,9 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) { lastInterval.completedAt = timestamp; } } + if (status === 'pending' || status === 'in_progress' || status === 'deleted') { + closeOpenReviewIntervals(task, timestamp); + } task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined; task.historyEvents = appendHistoryEvent(task.historyEvents, { @@ -531,16 +561,16 @@ function addTaskComment(paths, taskRef, text, options = {}) { const comment = { id: options.id || crypto.randomUUID(), author: - typeof options.author === 'string' && options.author.trim() - ? options.author.trim() - : 'user', + typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user', text, createdAt: typeof options.createdAt === 'string' && options.createdAt.trim() ? options.createdAt.trim() : nowIso(), type: options.type || 'regular', - ...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}), + ...(normalizeTaskRefs(options.taskRefs) + ? { taskRefs: normalizeTaskRefs(options.taskRefs) } + : {}), ...(Array.isArray(options.attachments) && options.attachments.length > 0 ? { attachments: options.attachments } : {}), @@ -711,10 +741,14 @@ function getTaskFreshness(task) { function compareTasksByFreshness(a, b) { const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a); if (freshnessDiff !== 0) return freshnessDiff; - const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { - numeric: true, - sensitivity: 'base', - }); + const byDisplay = String(a.displayId || a.id).localeCompare( + String(b.displayId || b.id), + undefined, + { + numeric: true, + sensitivity: 'base', + } + ); if (byDisplay !== 0) return byDisplay; return String(a.id).localeCompare(String(b.id), undefined, { numeric: true, @@ -756,7 +790,9 @@ function formatTaskBriefing(paths, teamName, memberName) { in_progress: activeTasks.filter((task) => task.status === 'in_progress'), needs_fix: activeTasks.filter((task) => { const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; - return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'; + return ( + task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix' + ); }), pending: activeTasks.filter((task) => { const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index fbada8cd..01eb7170 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => { const address = server.address(); return { baseUrl: `http://127.0.0.1:${address.port}`, - close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + close: async () => + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ), }; } @@ -146,8 +149,12 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Implement carefully'); expect(briefing).toContain('Working directory: /tmp/project-x'); expect(briefing).toContain('Task briefing for bob:'); - expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.'); - expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); + expect(briefing).toContain( + 'Use task_briefing as your primary working queue whenever you need to see assigned work.' + ); + expect(briefing).toContain( + 'Use task_list only to search/browse inventory rows, not as your working queue.' + ); expect(briefing).toContain('member_work_sync_status and member_work_sync_report'); expect(briefing).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' @@ -175,9 +182,7 @@ describe('agent-teams-controller API', () => { ); expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send'); expect(briefing).toContain('OpenCode bootstrap silence rule'); - expect(briefing).toContain( - 'If it shows no actionable tasks, stop and wait silently.' - ); + expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.'); expect(briefing).toContain( 'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"' ); @@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => { owner: 'bob', }); controller.tasks.completeTask(completedTask.id, 'bob'); - controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' }); + controller.tasks.addTaskComment(activeTask.id, { + from: 'bob', + text: 'Resumed work with latest context.', + }); const needsFixTask = controller.tasks.createTask({ subject: 'Fix after review', owner: 'bob', @@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => { expect(ownerInbox[0].text).toContain('task_get'); expect(ownerInbox[0].text).toContain('task_start'); expect(ownerInbox[0].text).toContain('task_add_comment'); - expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[0].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[0].text).toContain( 'If you are busy, blocked, or still need more context, immediately add a short task comment' ); @@ -527,7 +537,9 @@ describe('agent-teams-controller API', () => { expect(ownerInbox[0].text).toContain('Check the migration plan first.'); expect(ownerInbox[0].leadSessionId).toBe('lead-session-1'); expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`); - expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[3].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[3].text).toContain('task_add_comment'); const briefing = await controller.tasks.taskBriefing('bob'); @@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain(`#${reviewTask.displayId}`); expect(briefing).toContain('reason=review_reviewer_missing'); expect(briefing).toContain(`#${completedTask.displayId}`); - expect(briefing).not.toContain( - 'Completed task description should stay out of compact rows' - ); + expect(briefing).not.toContain('Completed task description should stay out of compact rows'); expect(briefing).toContain(`#${approvedTask.displayId}`); expect(briefing).toContain('Counters: actionable=4, awareness=3'); }); @@ -709,12 +719,7 @@ describe('agent-teams-controller API', () => { const firstEvent = restored.historyEvents[0]; expect(firstEvent.status).toBe('pending'); const statusChanges = restored.historyEvents.slice(1).map((e) => e.to); - expect(statusChanges).toEqual([ - 'in_progress', - 'completed', - 'deleted', - 'pending', - ]); + expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']); }); it('tracks owner assignment history without duplicate same-owner events', () => { @@ -804,6 +809,10 @@ describe('agent-teams-controller API', () => { expect(reviewEvent.from).toBe('review'); expect(reviewEvent.to).toBe('review'); expect(reviewEvent.actor).toBe('alice'); + expect(updatedTask.reviewIntervals).toHaveLength(1); + expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice'); + expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy(); + expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined(); // Idempotent: calling again should also succeed without duplicate events const again = controller.review.startReview(task.id, { from: 'alice' }); @@ -811,6 +820,35 @@ describe('agent-teams-controller API', () => { const reloaded = controller.tasks.getTask(task.id); const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started'); expect(startedEvents).toHaveLength(1); + expect(reloaded.reviewIntervals).toHaveLength(1); + }); + + it('closes review intervals when review is approved or changes are requested', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' }); + + controller.tasks.completeTask(approvedTask.id, 'bob'); + controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' }); + controller.review.startReview(approvedTask.id, { from: 'alice' }); + const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' }); + + expect(approved.reviewIntervals).toHaveLength(1); + expect(approved.reviewIntervals[0].reviewer).toBe('alice'); + expect(approved.reviewIntervals[0].completedAt).toBeTruthy(); + + const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' }); + controller.tasks.completeTask(changesTask.id, 'bob'); + controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' }); + controller.review.startReview(changesTask.id, { from: 'alice' }); + const changed = controller.review.requestChanges(changesTask.id, { + from: 'alice', + comment: 'Needs a fix.', + }); + + expect(changed.reviewIntervals).toHaveLength(1); + expect(changed.reviewIntervals[0].reviewer).toBe('alice'); + expect(changed.reviewIntervals[0].completedAt).toBeTruthy(); }); it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => { @@ -841,7 +879,10 @@ describe('agent-teams-controller API', () => { it('uses the assigned reviewer when review_start omits from', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' }); + const task = controller.tasks.createTask({ + subject: 'Queued for implicit reviewer', + owner: 'bob', + }); controller.tasks.completeTask(task.id, 'bob'); controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); @@ -866,15 +907,23 @@ describe('agent-teams-controller API', () => { 'must be completed before approval' ); - const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' }); + const completedTask = controller.tasks.createTask({ + subject: 'Completed but not review', + owner: 'bob', + }); controller.tasks.completeTask(completedTask.id, 'bob'); expect(() => controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' }) ).toThrow('must be in review before requesting changes'); - const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' }); + const deletedTask = controller.tasks.createTask({ + subject: 'Deleted review task', + owner: 'bob', + }); controller.tasks.softDeleteTask(deletedTask.id, 'bob'); - expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted'); + expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow( + 'is deleted' + ); expect(() => controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' }) ).toThrow('is deleted'); @@ -885,13 +934,19 @@ describe('agent-teams-controller API', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' }); + const pendingTask = controller.tasks.createTask({ + subject: 'Pending implementation', + owner: 'bob', + }); expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow( 'must be completed before starting review' ); expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none'); - const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' }); + const completedTask = controller.tasks.createTask({ + subject: 'Completed without review request', + owner: 'bob', + }); controller.tasks.completeTask(completedTask.id, 'bob'); expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow( 'must be in review before starting review' @@ -907,12 +962,18 @@ describe('agent-teams-controller API', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' }); + const pendingTask = controller.tasks.createTask({ + subject: 'Kanban bypass pending', + owner: 'bob', + }); expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow( 'must be completed before moving to APPROVED column' ); - const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' }); + const completedTask = controller.tasks.createTask({ + subject: 'Kanban bypass completed', + owner: 'bob', + }); controller.tasks.completeTask(completedTask.id, 'bob'); expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow( 'must be in review before moving to REVIEW column' @@ -938,9 +999,9 @@ describe('agent-teams-controller API', () => { controller.review.startReview(task.id, { from: 'alice' }); controller.review.approveReview(task.id, { from: 'alice' }); - expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow( - 'is already approved' - ); + expect(() => + controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }) + ).toThrow('is already approved'); expect(controller.tasks.getTask(task.id).reviewState).toBe('approved'); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved'); }); @@ -963,7 +1024,9 @@ describe('agent-teams-controller API', () => { controller.review.startReview(task.id, { from: 'alice' }); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review'); expect( - controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started') + controller.tasks + .getTask(task.id) + .historyEvents.filter((event) => event.type === 'review_started') ).toHaveLength(1); controller.review.approveReview(task.id, { from: 'alice' }); @@ -976,7 +1039,9 @@ describe('agent-teams-controller API', () => { expect(approvedAgain.alreadyApproved).toBe(true); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved'); expect( - controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved') + controller.tasks + .getTask(task.id) + .historyEvents.filter((event) => event.type === 'review_approved') ).toHaveLength(1); }); @@ -1189,7 +1254,11 @@ describe('agent-teams-controller API', () => { it('wakes task owner on regular comment from another member', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false }); + const task = controller.tasks.createTask({ + subject: 'Investigate', + owner: 'bob', + notifyOwner: false, + }); const commented = controller.tasks.addTaskComment(task.id, { from: 'alice', @@ -1354,7 +1423,10 @@ describe('agent-teams-controller API', () => { it('rejects task comments from unknown authors', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false }); + const task = controller.tasks.createTask({ + subject: 'Reject unknown author', + notifyOwner: false, + }); expect(() => controller.tasks.addTaskComment(task.id, { @@ -1374,7 +1446,10 @@ describe('agent-teams-controller API', () => { claudeDir, allowUserMessageSender: false, }); - const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false }); + const task = appController.tasks.createTask({ + subject: 'Reserved comment authors', + notifyOwner: false, + }); const appComment = appController.tasks.addTaskComment(task.id, { from: 'user', @@ -1803,11 +1878,19 @@ describe('agent-teams-controller API', () => { ); const controller = createController({ teamName: 'my-team', claudeDir }); - const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' }); + const leadOwnedTask = controller.tasks.createTask({ + subject: 'Lead alias owner', + owner: 'lead', + }); expect(leadOwnedTask.owner).toBe('leadbot'); - expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe( + false + ); - const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' }); + const reassignedTask = controller.tasks.createTask({ + subject: 'Reassign alias owner', + owner: 'bob', + }); expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot'); controller.kanban.addReviewer('lead'); @@ -1822,8 +1905,12 @@ describe('agent-teams-controller API', () => { .historyEvents.filter((event) => event.type === 'review_requested') .at(-1); expect(requested.reviewer).toBe('leadbot'); - expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true); - expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe( + true + ); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe( + false + ); }); it('rejects task_briefing for unknown members', async () => { @@ -1879,7 +1966,10 @@ describe('agent-teams-controller API', () => { it('clears kanban tasks and column order when task_set_status deletes a review task', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' }); + const task = controller.tasks.createTask({ + subject: 'Generic status delete cleanup', + owner: 'bob', + }); controller.tasks.completeTask(task.id, 'bob'); controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); @@ -1956,7 +2046,10 @@ describe('agent-teams-controller API', () => { it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' }); + const task = controller.tasks.createTask({ + subject: 'Do not unapprove directly', + owner: 'bob', + }); controller.tasks.completeTask(task.id, 'bob'); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); @@ -1980,11 +2073,13 @@ describe('agent-teams-controller API', () => { const controller = createController({ teamName: 'my-team', claudeDir }); const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' }); - expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob'); - controller.tasks.completeTask(task.id, 'bob'); - expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow( - 'Unknown reviewer: boob' + expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow( + 'Unknown task owner: boob' ); + controller.tasks.completeTask(task.id, 'bob'); + expect(() => + controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' }) + ).toThrow('Unknown reviewer: boob'); const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`); const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8')); @@ -2006,8 +2101,12 @@ describe('agent-teams-controller API', () => { controller.tasks.softDeleteTask(task.id, 'bob'); - expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work'); - expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status'); + expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow( + 'use task_restore before starting work' + ); + expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow( + 'use task_restore before changing status' + ); expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow( 'use task_restore before changing status' ); @@ -2020,7 +2119,10 @@ describe('agent-teams-controller API', () => { it('rejects task_restore for non-deleted tasks', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' }); + const task = controller.tasks.createTask({ + subject: 'Approved task must stay approved', + owner: 'bob', + }); controller.tasks.completeTask(task.id, 'bob'); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); @@ -2047,7 +2149,9 @@ describe('agent-teams-controller API', () => { delete state.tasks[task.id]; fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2)); - expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id); + expect( + controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id) + ).toContain(task.id); expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0); }); @@ -2090,7 +2194,10 @@ describe('agent-teams-controller API', () => { config.members.push({ name: 'carol', role: 'reviewer' }); fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' }); + const task = controller.tasks.createTask({ + subject: 'Repair mismatched reviewer actor', + owner: 'bob', + }); controller.tasks.completeTask(task.id, 'bob'); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); @@ -2124,7 +2231,11 @@ describe('agent-teams-controller API', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); const longSubject = `Long subject ${'x'.repeat(5000)}`; - const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false }); + const task = controller.tasks.createTask({ + subject: longSubject, + owner: 'bob', + notifyOwner: false, + }); const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'); fs.writeFileSync( kanbanPath, @@ -2147,7 +2258,11 @@ describe('agent-teams-controller API', () => { 'utf8' ); for (let index = 0; index < 30; index += 1) { - fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8'); + fs.writeFileSync( + path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), + '{ bad json', + 'utf8' + ); } const briefing = await controller.tasks.leadBriefing(); diff --git a/docs/screenshots/agent-graph-four-participants-layout-preview.svg b/docs/screenshots/agent-graph-four-participants-layout-preview.svg new file mode 100644 index 00000000..2a03d844 --- /dev/null +++ b/docs/screenshots/agent-graph-four-participants-layout-preview.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4 participants - current radial layout + Strict small-team preset: top / right / bottom / left around Lead + + + + + + Lead + center reserved zone + + + + + Participant 1 + top side + + slot 1 + + + + + + Participant 2 + right side + + slot 2 + + + + + + Participant 3 + bottom side + + slot 3 + + + + + + Participant 4 + left side + + slot 4 + + \ No newline at end of file diff --git a/docs/screenshots/agent-graph-row-orbit-layout-preview.svg b/docs/screenshots/agent-graph-row-orbit-layout-preview.svg new file mode 100644 index 00000000..4ffbd6d1 --- /dev/null +++ b/docs/screenshots/agent-graph-row-orbit-layout-preview.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8 participants + 3 top / 2 at lead level / 3 bottom + 12 participants + 4 top / 2 + lead + 2 middle / 4 bottom + + + + + + top row + lead row + bottom row + + + + + + + + + + + + + + + lead + + + alicereviewer + novadeveloper + tomdeveloper + jackdeveloper + atlasassistant + bobdeveloper + mayaqa + kaiops + + + + + + + top row + lead row + bottom row + + + + + + + + + + + + + + + + + + + lead + + + alice + nova + tom + jack + atlas + bob + maya + kai + ivy + rex + zoe + sam + + diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f27fb60f..9b69acad 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -132,6 +132,7 @@ export default defineConfig({ } }, renderer: { + cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'), optimizeDeps: { include: ['@codemirror/language-data'], exclude: ['@claude-teams/agent-graph'] diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index a08ea95c..2f93be0e 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -50,6 +50,8 @@ export interface SlotFrame { taskColumnCount: number; } +type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead'; + export interface StableSlotLayoutSnapshot { version: GraphLayoutPort['version']; teamName: string; @@ -61,6 +63,7 @@ export interface StableSlotLayoutSnapshot { launchAnchor: { x: number; y: number } | null; leadCentralReservedBlock: StableRect; runtimeCentralExclusion: StableRect; + ownerSlotLayoutKind: OwnerSlotLayoutKind; centralCollisionRects: StableRect[]; memberSlotFrames: SlotFrame[]; memberSlotFrameByOwnerId: Map; @@ -104,6 +107,20 @@ interface RingLayoutState { type RingLayoutStateMap = ReadonlyMap; +interface PlannedMemberSlotLayout { + frames: SlotFrame[]; + kind: OwnerSlotLayoutKind; +} + +interface RowOrbitSlotConfig { + footprint: OwnerFootprint; + assignment: GraphOwnerSlotAssignment; + rowIndex: number; + columnIndex: number; + columnCount: number; + band: 'top' | 'middle' | 'bottom'; +} + const SLOT_GEOMETRY = { ...STABLE_SLOT_GEOMETRY, activityColumnHeight: @@ -129,11 +146,19 @@ const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; const GEOMETRY_EPSILON = 0.001; const FEED_HEADER_BOTTOM_GAP = 4; -const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; -const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7; -const GRID_UNDER_LEAD_COLUMN_COUNT = 2; +const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96; +const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5; +const STRICT_SMALL_TEAM_RADIUS_STEP = 24; +const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2; +const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3; +const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6; const GRID_UNDER_LEAD_LEAD_GAP = 77.7; const GRID_UNDER_LEAD_ROW_GAP = 77.7; +const ROW_ORBIT_MIN_OWNER_COUNT = 6; +const ROW_ORBIT_MAX_OWNER_COUNT = 12; +const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap); +const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP); +const ROW_ORBIT_CENTRAL_GAP = 160; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< @@ -159,14 +184,48 @@ const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } }, { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } }, ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] }, + { assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] }, + { assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] }, + { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: SECTOR_VECTORS[3] }, + { assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] }, + { assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] }, + ], ]; const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray> = SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment)); -const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map( - SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) => - layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) - ) + +const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly> = { + 6: [3, 2, 3], + 7: [3, 2, 2], + 8: [3, 2, 3], + 9: [3, 2, 2, 2], + 10: [3, 2, 2, 3], + 11: [3, 3, 2, 3], + 12: [3, 3, 3, 3], +}; + +const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly< + Record +> = Object.fromEntries( + Object.entries(ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT).map(([ownerCount, rowCounts]) => [ + Number(ownerCount), + rowCounts.flatMap((columnCount, rowIndex) => + Array.from({ length: columnCount }, (_, columnIndex) => ({ + ringIndex: rowIndex, + sectorIndex: columnIndex, + })) + ), + ]) ); export function buildStableSlotLayoutSnapshot({ @@ -201,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({ SLOT_GEOMETRY.centralPadding ); - const memberSlotFrames = + const memberSlotLayout = (layout?.mode ?? 'radial') === 'grid-under-lead' - ? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects) + ? { + frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects), + kind: 'grid-under-lead' as const, + } : planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout); + const memberSlotFrames = memberSlotLayout.frames; const memberSlotFrameByOwnerId = new Map( memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) ); @@ -223,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({ launchAnchor: null, leadCentralReservedBlock, runtimeCentralExclusion, + ownerSlotLayoutKind: memberSlotLayout.kind, centralCollisionRects, memberSlotFrames, memberSlotFrameByOwnerId, @@ -457,6 +521,21 @@ export function resolveNearestSlotAssignment(args: { return null; } + if (args.snapshot.ownerSlotLayoutKind === 'row-orbit') { + const rowOrbitCandidate = resolveNearestRowOrbitSlotAssignment({ + ownerId: args.ownerId, + ownerX: args.ownerX, + ownerY: args.ownerY, + currentFrame, + ownerFootprints: allFootprints, + snapshot: args.snapshot, + layout: args.layout, + }); + if (rowOrbitCandidate) { + return rowOrbitCandidate; + } + } + const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({ ownerId: args.ownerId, ownerX: args.ownerX, @@ -568,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: { return null; } + return resolveNearestExistingFrameSlotAssignment({ + ownerId: args.ownerId, + ownerX: args.ownerX, + ownerY: args.ownerY, + currentFrame: args.currentFrame, + frames: strictFrames, + }); +} + +function resolveNearestRowOrbitSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + currentFrame: SlotFrame; + ownerFootprints: readonly OwnerFootprint[]; + snapshot: StableSlotLayoutSnapshot; + layout?: GraphLayoutPort; +}): NearestSlotAssignmentResult | null { + const allowedAssignments = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[args.ownerFootprints.length]; + if (!allowedAssignments || allowedAssignments.length < args.ownerFootprints.length) { + return null; + } + + const baseAssignments = Object.fromEntries( + args.snapshot.memberSlotFrames.map((frame) => [ + frame.ownerId, + { + ringIndex: frame.ringIndex, + sectorIndex: frame.sectorIndex, + }, + ]) + ); + let best: RankedNearestSlotAssignmentResult | null = null; + + for (const assignment of allowedAssignments) { + const occupiedFrame = args.snapshot.memberSlotFrames.find( + (frame) => + frame.ownerId !== args.ownerId && + frame.ringIndex === assignment.ringIndex && + frame.sectorIndex === assignment.sectorIndex + ); + const simulatedAssignments: Record = { + ...baseAssignments, + [args.ownerId]: assignment, + }; + if (occupiedFrame) { + simulatedAssignments[occupiedFrame.ownerId] = { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }; + } + + const frames = planRowOrbitOwnerSlots( + args.ownerFootprints, + args.snapshot.centralCollisionRects, + args.snapshot.runtimeCentralExclusion, + { + version: args.layout?.version ?? 'stable-slots-v1', + mode: args.layout?.mode ?? 'radial', + ownerOrder: + args.layout?.ownerOrder ?? args.ownerFootprints.map((footprint) => footprint.ownerId), + slotAssignments: simulatedAssignments, + } + ); + const previewFrame = frames?.find((frame) => frame.ownerId === args.ownerId); + if (!previewFrame) { + continue; + } + + const dx = previewFrame.ownerX - args.ownerX; + const dy = previewFrame.ownerY - args.ownerY; + const candidate: RankedNearestSlotAssignmentResult = { + assignment, + displacedOwnerId: occupiedFrame?.ownerId, + displacedAssignment: occupiedFrame + ? { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + } + : undefined, + previewOwnerX: previewFrame.ownerX, + previewOwnerY: previewFrame.ownerY, + distanceSquared: dx * dx + dy * dy, + }; + + if (!best || candidate.distanceSquared < best.distanceSquared) { + best = candidate; + } + } + + return best + ? { + assignment: best.assignment, + displacedOwnerId: best.displacedOwnerId, + displacedAssignment: best.displacedAssignment, + previewOwnerX: best.previewOwnerX, + previewOwnerY: best.previewOwnerY, + } + : null; +} + +function resolveNearestExistingFrameSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + currentFrame: SlotFrame; + frames: readonly SlotFrame[]; +}): NearestSlotAssignmentResult | null { let best: { frame: SlotFrame; distanceSquared: number; } | null = null; - for (const frame of strictFrames) { + for (const frame of args.frames) { const dx = frame.ownerX - args.ownerX; const dy = frame.ownerY - args.ownerY; const distanceSquared = dx * dx + dy * dy; @@ -613,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: { } function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null { - if (frames.length === 0 || frames.length > 4) { + if (frames.length === 0 || frames.length > 6) { return null; } const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length]; @@ -968,7 +1155,22 @@ function planOwnerSlots( centralCollisionRects: readonly StableRect[], runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort -): SlotFrame[] { +): PlannedMemberSlotLayout { + const rowOrbitFrames = shouldUseRowOrbitLayout(ownerFootprints, layout) + ? planRowOrbitOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ) + : null; + if (rowOrbitFrames) { + return { + frames: rowOrbitFrames, + kind: 'row-orbit', + }; + } + const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout) ? planStrictSmallTeamOwnerSlots( ownerFootprints, @@ -978,7 +1180,10 @@ function planOwnerSlots( ) : null; if (strictSmallTeamFrames) { - return strictSmallTeamFrames; + return { + frames: strictSmallTeamFrames, + kind: 'radial-sector', + }; } const placedFrames: SlotFrame[] = []; @@ -1002,7 +1207,354 @@ function planOwnerSlots( commitRingPlacement(ringStates, resolvedFrame, footprint); } - return placedFrames; + return { + frames: placedFrames, + kind: 'radial-sector', + }; +} + +function shouldUseRowOrbitLayout( + ownerFootprints: readonly OwnerFootprint[], + layout?: GraphLayoutPort +): boolean { + if ( + ownerFootprints.length < ROW_ORBIT_MIN_OWNER_COUNT || + ownerFootprints.length > ROW_ORBIT_MAX_OWNER_COUNT + ) { + return false; + } + + const preset = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[ownerFootprints.length]; + if (!preset || preset.length < ownerFootprints.length) { + return false; + } + const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length]; + if (!rowCounts) { + return false; + } + const actualAssignments = ownerFootprints + .map((footprint) => layout?.slotAssignments?.[footprint.ownerId]) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null); + const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments( + ownerFootprints.length, + actualAssignments + ); + + const actualAssignmentKeys = actualAssignments + .map((assignment) => + normalizeRowOrbitAssignment(assignment, ownerFootprints.length, rowCounts, { + useLegacySixTwoRowAssignments, + }) + ) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null) + .map((assignment) => buildAssignmentKey(assignment)) + .sort(); + const allowedAssignmentKeys = new Set(preset.map((assignment) => buildAssignmentKey(assignment))); + + if (actualAssignmentKeys.length !== ownerFootprints.length) { + return false; + } + + const uniqueAssignmentKeys = new Set(actualAssignmentKeys); + if (uniqueAssignmentKeys.size !== actualAssignmentKeys.length) { + return false; + } + + for (const assignmentKey of actualAssignmentKeys) { + if (!allowedAssignmentKeys.has(assignmentKey)) { + return false; + } + } + + return true; +} + +function planRowOrbitOwnerSlots( + ownerFootprints: readonly OwnerFootprint[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] | null { + const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length]; + if (!rowCounts) { + return null; + } + + const slotConfigs = buildRowOrbitSlotConfigs(ownerFootprints, rowCounts, layout); + if (!slotConfigs) { + return null; + } + + const frames = buildRowOrbitSlotFrames(slotConfigs, rowCounts, runtimeCentralExclusion); + const allValid = frames.every((frame, frameIndex) => + isSlotFramePlacementValid( + frame, + frames.filter((_, index) => index !== frameIndex), + centralCollisionRects + ) + ); + return allValid ? frames : null; +} + +function buildRowOrbitSlotConfigs( + ownerFootprints: readonly OwnerFootprint[], + rowCounts: readonly number[], + layout?: GraphLayoutPort +): RowOrbitSlotConfig[] | null { + const rowCount = rowCounts.length; + const middleRowIndex = rowCount === 3 ? 1 : -1; + const configs: RowOrbitSlotConfig[] = []; + const actualAssignments = ownerFootprints + .map((footprint) => layout?.slotAssignments?.[footprint.ownerId]) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null); + const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments( + ownerFootprints.length, + actualAssignments + ); + + for (const footprint of ownerFootprints) { + const assignment = layout?.slotAssignments?.[footprint.ownerId]; + if (!assignment) { + return null; + } + + const rowOrbitAssignment = normalizeRowOrbitAssignment( + assignment, + ownerFootprints.length, + rowCounts, + { + useLegacySixTwoRowAssignments, + } + ); + if (!rowOrbitAssignment) { + return null; + } + + const columnCount = rowCounts[rowOrbitAssignment.ringIndex]; + if ( + columnCount == null || + rowOrbitAssignment.sectorIndex < 0 || + rowOrbitAssignment.sectorIndex >= columnCount + ) { + return null; + } + + configs.push({ + footprint, + assignment: rowOrbitAssignment, + rowIndex: rowOrbitAssignment.ringIndex, + columnIndex: rowOrbitAssignment.sectorIndex, + columnCount, + band: resolveRowOrbitBand(rowOrbitAssignment.ringIndex, rowCount, middleRowIndex), + }); + } + + return configs; +} + +function normalizeRowOrbitAssignment( + assignment: GraphOwnerSlotAssignment, + ownerCount: number, + rowCounts: readonly number[], + options: { useLegacySixTwoRowAssignments?: boolean } = {} +): GraphOwnerSlotAssignment | null { + if ( + options.useLegacySixTwoRowAssignments === true && + ownerCount === 6 && + assignment.ringIndex === 1 && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < 3 + ) { + return { + ringIndex: 2, + sectorIndex: assignment.sectorIndex, + }; + } + + const directColumnCount = rowCounts[assignment.ringIndex]; + if ( + directColumnCount != null && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < directColumnCount + ) { + return assignment; + } + + if ( + ownerCount === 6 && + assignment.ringIndex === 0 && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < 6 + ) { + return { + ringIndex: assignment.sectorIndex < 3 ? 0 : 2, + sectorIndex: assignment.sectorIndex % 3, + }; + } + + return null; +} + +function shouldNormalizeLegacySixTwoRowAssignments( + ownerCount: number, + assignments: readonly GraphOwnerSlotAssignment[] +): boolean { + if (ownerCount !== 6 || assignments.length !== ownerCount) { + return false; + } + + return assignments.some( + (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 + ); +} + +function resolveRowOrbitBand( + rowIndex: number, + rowCount: number, + middleRowIndex: number +): RowOrbitSlotConfig['band'] { + if (middleRowIndex >= 0) { + if (rowIndex < middleRowIndex) { + return 'top'; + } + return rowIndex === middleRowIndex ? 'middle' : 'bottom'; + } + return rowIndex < rowCount / 2 ? 'top' : 'bottom'; +} + +function buildRowOrbitSlotFrames( + slotConfigs: readonly RowOrbitSlotConfig[], + rowCounts: readonly number[], + runtimeCentralExclusion: StableRect +): SlotFrame[] { + const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length); + const middleRowIndex = rowCounts.length === 3 ? 1 : -1; + const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion); + const framesByOwnerId = new Map(); + const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth)); + + for (const row of rowConfigs) { + if (row.length === 0) { + continue; + } + + if (row[0]?.band === 'middle') { + for (const config of row) { + const ownerX = + config.columnIndex === 0 + ? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2 + : runtimeCentralExclusion.right + + ROW_ORBIT_CENTRAL_GAP + + config.footprint.slotWidth / 2; + framesByOwnerId.set( + config.footprint.ownerId, + buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, 0) + ); + } + continue; + } + + const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0; + const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length; + const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth); + let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2; + for (const config of row) { + const ownerX = + nextLeft + + columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) + + config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP + + columnWidths[config.columnIndex]! / 2; + const ownerY = rowTop + getOwnerAnchorTopOffset(); + framesByOwnerId.set( + config.footprint.ownerId, + buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, ownerY) + ); + } + } + + return slotConfigs.flatMap((config) => { + const frame = framesByOwnerId.get(config.footprint.ownerId); + return frame ? [frame] : []; + }); +} + +function groupRowOrbitSlotConfigs( + slotConfigs: readonly RowOrbitSlotConfig[], + rowCount: number +): RowOrbitSlotConfig[][] { + const rows: RowOrbitSlotConfig[][] = Array.from({ length: rowCount }, () => []); + for (const config of slotConfigs) { + rows[config.rowIndex]!.push(config); + } + for (const row of rows) { + row.sort((left, right) => left.columnIndex - right.columnIndex); + } + return rows; +} + +function resolveRowOrbitRowTops( + rowConfigs: readonly (readonly RowOrbitSlotConfig[])[], + middleRowIndex: number, + runtimeCentralExclusion: StableRect +): Map { + const topByRowIndex = new Map(); + let nextTopRowBottom = runtimeCentralExclusion.top - ROW_ORBIT_CENTRAL_GAP; + for ( + let rowIndex = middleRowIndex >= 0 ? middleRowIndex - 1 : rowConfigs.length / 2 - 1; + rowIndex >= 0; + rowIndex -= 1 + ) { + const row = rowConfigs[rowIndex] ?? []; + if (row.length === 0) { + continue; + } + const rowHeight = getRowOrbitRowHeight(row); + const rowTop = nextTopRowBottom - rowHeight; + topByRowIndex.set(rowIndex, rowTop); + nextTopRowBottom = rowTop - ROW_ORBIT_VERTICAL_GAP; + } + + let nextBottomRowTop = runtimeCentralExclusion.bottom + ROW_ORBIT_CENTRAL_GAP; + for ( + let rowIndex = middleRowIndex >= 0 ? middleRowIndex + 1 : Math.ceil(rowConfigs.length / 2); + rowIndex < rowConfigs.length; + rowIndex += 1 + ) { + const row = rowConfigs[rowIndex] ?? []; + if (row.length === 0) { + continue; + } + topByRowIndex.set(rowIndex, nextBottomRowTop); + nextBottomRowTop += getRowOrbitRowHeight(row) + ROW_ORBIT_VERTICAL_GAP; + } + + return topByRowIndex; +} + +function resolveRowOrbitColumnWidths( + row: readonly RowOrbitSlotConfig[], + columnCount: number, + fallbackColumnWidth: number +): number[] { + const columnWidths = Array.from({ length: columnCount }, () => fallbackColumnWidth); + for (const config of row) { + columnWidths[config.columnIndex] = Math.max( + columnWidths[config.columnIndex] ?? fallbackColumnWidth, + config.footprint.slotWidth + ); + } + return columnWidths; +} + +function getRowOrbitRowWidth(columnWidths: readonly number[]): number { + return ( + columnWidths.reduce((sum, width) => sum + width, 0) + + Math.max(0, columnWidths.length - 1) * ROW_ORBIT_HORIZONTAL_GAP + ); +} + +function getRowOrbitRowHeight(row: readonly RowOrbitSlotConfig[]): number { + return Math.max(...row.map((config) => config.footprint.slotHeight)); } function planGridUnderLeadOwnerSlots( @@ -1012,16 +1564,14 @@ function planGridUnderLeadOwnerSlots( const frames: SlotFrame[] = []; const centralBlock = unionRects([...centralCollisionRects]); let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP; + const columnCount = getGridUnderLeadColumnCount(ownerFootprints.length); for ( let rowStartIndex = 0; rowStartIndex < ownerFootprints.length; - rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT + rowStartIndex += columnCount ) { - const rowFootprints = ownerFootprints.slice( - rowStartIndex, - rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT - ); + const rowFootprints = ownerFootprints.slice(rowStartIndex, rowStartIndex + columnCount); const rowWidth = rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) + Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap; @@ -1035,7 +1585,7 @@ function planGridUnderLeadOwnerSlots( buildSlotFrameAtOwnerAnchor( footprint, { - ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT), + ringIndex: Math.floor(rowStartIndex / columnCount), sectorIndex: columnIndex, }, ownerX, @@ -1051,11 +1601,17 @@ function planGridUnderLeadOwnerSlots( return frames; } +function getGridUnderLeadColumnCount(ownerCount: number): number { + return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT + ? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT + : GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT; +} + function shouldUseStrictSmallTeamCardinalLayout( ownerFootprints: readonly OwnerFootprint[], layout?: GraphLayoutPort ): boolean { - if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + if (ownerFootprints.length === 0 || ownerFootprints.length > 6) { return false; } @@ -1090,7 +1646,7 @@ function planStrictSmallTeamOwnerSlots( runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort ): SlotFrame[] | null { - if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + if (ownerFootprints.length === 0 || ownerFootprints.length > 6) { return null; } @@ -1098,13 +1654,16 @@ function planStrictSmallTeamOwnerSlots( if (!preset || preset.length !== ownerFootprints.length) { return null; } + const vectorByAssignmentKey = new Map( + preset.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) + ); const slotConfigs = ownerFootprints.map((footprint) => { const assignment = layout?.slotAssignments?.[footprint.ownerId]; if (!assignment) { return null; } - const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment)); + const vector = vectorByAssignmentKey.get(buildAssignmentKey(assignment)); if (!vector) { return null; } @@ -1119,72 +1678,164 @@ function planStrictSmallTeamOwnerSlots( return null; } - const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis( + return packStrictSmallTeamOwnerSlots( slotConfigs.map((slot) => slot!), centralCollisionRects, runtimeCentralExclusion ); +} - for (let iteration = 0; iteration < 48; iteration += 1) { - const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP; - const frames = slotConfigs.map((slot) => { - const axis = resolveStrictSmallTeamVectorAxis(slot!.vector); - return buildSlotFrameAtRadiusWithVector( - slot!.footprint, - slot!.assignment, - baseRadiusByAxis[axis] + - (axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) + - radiusBump, - slot!.vector - ); - }); - const allValid = frames.every((frame, frameIndex) => - isSlotFramePlacementValid( - frame, - frames.filter((_, index) => index !== frameIndex), - centralCollisionRects - ) +function packStrictSmallTeamOwnerSlots( + slotConfigs: readonly { + footprint: OwnerFootprint; + assignment: GraphOwnerSlotAssignment; + vector: { x: number; y: number }; + }[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect +): SlotFrame[] | null { + const radii = slotConfigs.map((slot) => + resolveMinimumDirectionalRadiusForVector({ + vector: slot.vector, + footprint: slot.footprint, + centralCollisionRects, + runtimeCentralExclusion, + }) + ); + + for (let iteration = 0; iteration < STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS; iteration += 1) { + const frames = buildStrictSmallTeamFrames(slotConfigs, radii); + const invalidCentralIndex = frames.findIndex((frame) => + rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects) ); - if (allValid) { + if (invalidCentralIndex >= 0) { + radii[invalidCentralIndex] += STRICT_SMALL_TEAM_RADIUS_STEP; + continue; + } + + const conflict = findFirstOwnerSlotFrameConflict(frames); + if (!conflict) { return frames; } + + const nextLeftRadius = resolveMinimumRadiusAvoidingFrame({ + slotConfig: slotConfigs[conflict.leftIndex]!, + currentRadius: radii[conflict.leftIndex]!, + otherFrame: frames[conflict.rightIndex]!, + centralCollisionRects, + }); + const nextRightRadius = resolveMinimumRadiusAvoidingFrame({ + slotConfig: slotConfigs[conflict.rightIndex]!, + currentRadius: radii[conflict.rightIndex]!, + otherFrame: frames[conflict.leftIndex]!, + centralCollisionRects, + }); + + if (nextLeftRadius == null && nextRightRadius == null) { + return null; + } + + const leftIncrease = + nextLeftRadius == null + ? Number.POSITIVE_INFINITY + : nextLeftRadius - radii[conflict.leftIndex]!; + const rightIncrease = + nextRightRadius == null + ? Number.POSITIVE_INFINITY + : nextRightRadius - radii[conflict.rightIndex]!; + + if (leftIncrease <= rightIncrease) { + radii[conflict.leftIndex] = nextLeftRadius!; + } else { + radii[conflict.rightIndex] = nextRightRadius!; + } } return null; } -function resolveStrictSmallTeamRadiusByAxis( +function buildStrictSmallTeamFrames( slotConfigs: readonly { footprint: OwnerFootprint; + assignment: GraphOwnerSlotAssignment; vector: { x: number; y: number }; }[], - centralCollisionRects: readonly StableRect[], - runtimeCentralExclusion: StableRect -): Record<'horizontal' | 'vertical', number> { - const radiusByAxis = { - horizontal: 0, - vertical: 0, - }; - - for (const slot of slotConfigs) { - const axis = resolveStrictSmallTeamVectorAxis(slot.vector); - const radius = resolveMinimumDirectionalRadiusForVector({ - vector: slot.vector, - footprint: slot.footprint, - centralCollisionRects, - runtimeCentralExclusion, - }); - radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius); - } - - return radiusByAxis; + radii: readonly number[] +): SlotFrame[] { + return slotConfigs.map((slot, index) => + buildSlotFrameAtRadiusWithVector( + slot.footprint, + slot.assignment, + radii[index] ?? 0, + slot.vector + ) + ); } -function resolveStrictSmallTeamVectorAxis(vector: { - x: number; - y: number; -}): 'horizontal' | 'vertical' { - return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical'; +function findFirstOwnerSlotFrameConflict( + frames: readonly SlotFrame[] +): { leftIndex: number; rightIndex: number } | null { + for (const [leftIndex, left] of frames.entries()) { + for (let rightIndex = leftIndex + 1; rightIndex < frames.length; rightIndex += 1) { + const right = frames[rightIndex]!; + if (ownerSlotFramesOverlap(left.bounds, right.bounds)) { + return { leftIndex, rightIndex }; + } + } + } + return null; +} + +function resolveMinimumRadiusAvoidingFrame(args: { + slotConfig: { + footprint: OwnerFootprint; + assignment: GraphOwnerSlotAssignment; + vector: { x: number; y: number }; + }; + currentRadius: number; + otherFrame: SlotFrame; + centralCollisionRects: readonly StableRect[]; +}): number | null { + const canPlaceAtRadius = (radius: number): boolean => { + const frame = buildSlotFrameAtRadiusWithVector( + args.slotConfig.footprint, + args.slotConfig.assignment, + radius, + args.slotConfig.vector + ); + return ( + !rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects) && + !ownerSlotFramesOverlap(frame.bounds, args.otherFrame.bounds) + ); + }; + + if (canPlaceAtRadius(args.currentRadius)) { + return args.currentRadius; + } + + let low = args.currentRadius; + let high = Math.max(args.currentRadius + STRICT_SMALL_TEAM_RADIUS_STEP, args.currentRadius * 1.1); + let expansionCount = 0; + while (!canPlaceAtRadius(high) && expansionCount < 24) { + low = high; + high = Math.max(high + STRICT_SMALL_TEAM_RADIUS_STEP, high * 1.25); + expansionCount += 1; + } + + if (!canPlaceAtRadius(high)) { + return null; + } + + for (let iteration = 0; iteration < 24; iteration += 1) { + const mid = (low + high) / 2; + if (canPlaceAtRadius(mid)) { + high = mid; + } else { + low = mid; + } + } + + return Math.ceil(high + STRICT_SMALL_TEAM_RADIUS_EPSILON); } function buildPreferredAssignmentsMap( diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index cce9ae7b..21e2c3c2 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot { function toGraphLaunchVisualState( visualState: ReturnType['launchVisualState'] | undefined ): GraphNode['launchVisualState'] { - return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined); + if (!visualState) { + return undefined; + } + if (visualState === 'bootstrap_stalled') { + return 'runtime_pending'; + } + if (visualState === 'starting_stale') { + return 'spawning'; + } + return visualState; } export class TeamGraphAdapter { diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index 61fdbd3f..67f742ac 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -26,7 +26,7 @@ export const RecentProjectCard = ({ onClick={onClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised" + className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised" style={{ borderLeftColor: color.border, boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined, diff --git a/src/main/index.ts b/src/main/index.ts index fc20be26..9c168878 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Keep userData stable before any integration can initialize Electron storage. // Sentry must stay near the top to capture early errors after storage migration. +// eslint-disable-next-line simple-import-sort/imports -- userData migration must run before Sentry initializes Electron storage. import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import './sentry'; @@ -76,8 +77,9 @@ import { } from '@main/services/team/TeamMcpConfigBuilder'; import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { killTrackedCliProcesses } from '@main/utils/childProcess'; -import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { + APP_STARTUP_GET_STATUS, + APP_STARTUP_PROGRESS, CONTEXT_CHANGED, SCHEDULE_CHANGE, SKILLS_CHANGED, @@ -105,6 +107,7 @@ import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; +import { registerRendererLogHandlers } from './ipc/rendererLogs'; import { setReviewMainWindow } from './ipc/review'; import { setTmuxMainWindow } from './ipc/tmux'; import { @@ -209,7 +212,7 @@ import { } from './services'; import type { FileChangeEvent } from '@main/types'; -import type { TeamChangeEvent } from '@shared/types'; +import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types'; const logger = createLogger('App'); let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null; @@ -256,17 +259,27 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) — suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); -async function createOpenCodeRuntimeAdapterRegistry(): Promise { - const binaryPath = await ClaudeBinaryResolver.resolve(); +async function createOpenCodeRuntimeAdapterRegistry( + reportProgress: (phase: string, message: string) => void = () => undefined +): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve({ + onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message), + }); if (!binaryPath) { logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved'); + reportProgress( + 'runtime-unavailable', + 'Runtime not found. Continuing with limited launch support...' + ); openCodeLifecycleBridge = null; return new TeamRuntimeAdapterRegistry(); } + reportProgress('runtime-environment', 'Preparing runtime environment...'); const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); try { + reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ teamsBasePath: getTeamsBasePath(), provider: 'opencode', @@ -282,7 +295,10 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise reportProgress(`mcp-${phase}`, message), + }); const mcpEntry = mcpLaunchSpec.args[0]; if (mcpEntry) { bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; @@ -297,6 +313,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise | null = null; let rendererRecoveryAttempts = 0; +let servicesReady = false; +let rendererDidFinishLoad = false; +let fileWatcherStartupStarted = false; +let backgroundStartupTasksStarted = false; +let appStartupHandlersRegistered = false; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; @@ -636,6 +658,24 @@ const startupTimers = new Set>(); const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const STARTUP_RECOVERY_DELAY_MS = 10_000; const STARTUP_RECOVERY_CONCURRENCY = 1; +const appStartupStartedAt = Date.now(); +let appStartupSteps: AppStartupStep[] = [ + { + phase: 'boot', + message: 'Starting Agent Teams AI...', + startedAt: appStartupStartedAt, + updatedAt: appStartupStartedAt, + }, +]; +let appStartupStatus: AppStartupStatus = { + phase: 'boot', + message: 'Starting Agent Teams AI...', + ready: false, + error: null, + startedAt: appStartupStartedAt, + updatedAt: appStartupStartedAt, + steps: appStartupSteps, +}; function isShutdownStarted(): boolean { return shutdownComplete || shutdownPromise !== null; @@ -653,6 +693,74 @@ function scheduleStartupTask(action: () => void, delayMs: number): void { startupTimers.add(timer); } +function registerAppStartupHandlers(): void { + if (appStartupHandlersRegistered) { + return; + } + appStartupHandlersRegistered = true; + registerRendererLogHandlers(ipcMain); + ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus); +} + +function cloneStartupSteps(): AppStartupStep[] { + return appStartupSteps.map((step) => ({ ...step })); +} + +function updateStartupTimeline(update: Partial, now: number): void { + if (!update.phase && !update.message) { + return; + } + + const phase = update.phase ?? appStartupStatus.phase; + const message = update.message ?? appStartupStatus.message; + const current = appStartupSteps[appStartupSteps.length - 1]; + + if (current?.phase !== phase) { + if (current && !current.finishedAt) { + current.finishedAt = now; + current.durationMs = now - current.startedAt; + current.updatedAt = now; + } + appStartupSteps.push({ + phase, + message, + startedAt: now, + updatedAt: now, + }); + if (appStartupSteps.length > 32) { + appStartupSteps = appStartupSteps.slice(-32); + } + } else { + current.message = message; + current.updatedAt = now; + } +} + +function finishCurrentStartupStep(now: number): void { + const current = appStartupSteps[appStartupSteps.length - 1]; + if (!current || current.finishedAt) { + return; + } + current.finishedAt = now; + current.durationMs = now - current.startedAt; + current.updatedAt = now; +} + +function publishStartupStatus(update: Partial): void { + const now = Date.now(); + updateStartupTimeline(update, now); + if (update.ready === true || update.error) { + finishCurrentStartupStep(now); + } + appStartupStatus = { + ...appStartupStatus, + ...update, + updatedAt: now, + steps: cloneStartupSteps(), + }; + safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus); +} + async function runStartupJobsBounded( items: readonly T[], concurrency: number, @@ -1063,6 +1171,12 @@ function reconfigureLocalContextForClaudeRoot(): void { */ async function initializeServices(): Promise { logger.info('Initializing services...'); + publishStartupStatus({ + phase: 'services', + message: 'Preparing app services...', + ready: false, + error: null, + }); // Initialize SSH connection manager sshConnectionManager = new SshConnectionManager(); @@ -1167,10 +1281,20 @@ async function initializeServices(): Promise { teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName); }); - teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()); - await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) => - logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`) + publishStartupStatus({ + phase: 'runtime', + message: 'Resolving local runtime...', + }); + teamProvisioningService.setRuntimeAdapterRegistry( + await createOpenCodeRuntimeAdapterRegistry((phase, message) => + publishStartupStatus({ phase, message }) + ) ); + scheduleStartupTask(() => { + void cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) => + logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`) + ); + }, STARTUP_RECOVERY_DELAY_MS); // Startup GC: remove stale MCP config files from previous sessions (best-effort) void new TeamMcpConfigBuilder().gcStaleConfigs(); void teamDataService @@ -1267,6 +1391,10 @@ async function initializeServices(): Promise { const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const apiKeyService = new ApiKeyService(); providerConnectionService.setApiKeyService(apiKeyService); + publishStartupStatus({ + phase: 'settings', + message: 'Loading secure settings...', + }); await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -1448,6 +1576,11 @@ async function initializeServices(): Promise { // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. + publishStartupStatus({ + phase: 'ipc', + message: 'Wiring app actions...', + }); + // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, @@ -1529,6 +1662,10 @@ async function initializeServices(): Promise { } logger.info('Services initialized successfully'); + publishStartupStatus({ + phase: 'readying', + message: 'Finishing startup...', + }); } /** @@ -1717,6 +1854,85 @@ function syncTrafficLightPosition(win: BrowserWindow): void { safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); } +function attachMainWindowToServices(): void { + const win = mainWindow; + if (!win || win.isDestroyed()) { + return; + } + + notificationManager?.setMainWindow(win); + updaterService?.setMainWindow(win); + cliInstallerService?.setMainWindow(win); + setTmuxMainWindow(win); + ptyTerminalService?.setMainWindow(win); + teamProvisioningService?.setMainWindow(win); + codexAccountFeature?.setMainWindow(win); + setEditorMainWindow(win); + setReviewMainWindow(win); +} + +function runPostRendererStartupTasks(): void { + if (!servicesReady || !rendererDidFinishLoad || !mainWindow || mainWindow.isDestroyed()) { + return; + } + + if (!fileWatcherStartupStarted) { + fileWatcherStartupStarted = true; + // Start file watchers after both the visible window and main services are ready. + const activeContext = contextRegistry.getActive(); + if (process.platform === 'win32') { + scheduleStartupTask(() => { + if (!fileWatcherStartupStarted || !servicesReady || !rendererDidFinishLoad) { + return; + } + activeContext.startFileWatcher(); + }, 1500); + } else if (!isShutdownStarted()) { + activeContext.startFileWatcher(); + } + } + + if (backgroundStartupTasksStarted) { + return; + } + backgroundStartupTasksStarted = true; + + if (!isShutdownStarted()) { + scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000); + updaterService.startPeriodicCheck(60 * 60 * 1000); + } + + scheduleStartupTask( + () => { + void getTeamFsWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-fs-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + void getTeamDataWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-data-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + }, + process.platform === 'win32' ? 2500 : 1000 + ); + + scheduleStartupTask(() => { + void teamProvisioningService.warmup(); + teamDataService.startProcessHealthPolling(); + void schedulerService?.start(); + }, 5000); +} + function scheduleRendererRecovery(win: BrowserWindow): void { if (isShutdownStarted()) { return; @@ -1759,6 +1975,7 @@ function createWindow(): void { if (isShutdownStarted()) { return; } + rendererDidFinishLoad = false; const isMac = process.platform === 'darwin'; const isDev = process.env.NODE_ENV === 'development'; @@ -1780,7 +1997,7 @@ function createWindow(): void { backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), - title: 'Agent Teams UI', + title: 'Agent Teams AI', }); markRendererUnavailable(mainWindow); @@ -1850,6 +2067,7 @@ function createWindow(): void { if (isShutdownStarted()) { return; } + rendererDidFinishLoad = false; markRendererUnavailable(mainWindow); branchStatusService?.resetAllTracking(); }); @@ -1874,57 +2092,8 @@ function createWindow(): void { } }, 0); fullscreenSyncTimer.unref?.(); - // Start file watchers now that the window is visible and responsive. - // Deferred from initializeServices() to avoid blocking window creation - // with fs.watch() setup (especially slow on Windows with recursive watchers). - const activeContext = contextRegistry.getActive(); - if (process.platform === 'win32') { - // On Windows, delay FileWatcher startup to let the renderer complete - // its initial IPC calls without UV thread pool contention. Recursive - // fs.watch() on NTFS saturates all 4 default UV threads. - scheduleStartupTask(() => activeContext.startFileWatcher(), 1500); - } else { - if (!isShutdownStarted()) { - activeContext.startFileWatcher(); - } - } - - if (!isShutdownStarted()) { - scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000); - updaterService.startPeriodicCheck(60 * 60 * 1000); - } - - scheduleStartupTask( - () => { - void getTeamFsWorkerClient() - .prewarm() - .catch((error: unknown) => - logger.debug( - `[startup] team-fs-worker prewarm skipped: ${ - error instanceof Error ? error.message : String(error) - }` - ) - ); - void getTeamDataWorkerClient() - .prewarm() - .catch((error: unknown) => - logger.debug( - `[startup] team-data-worker prewarm skipped: ${ - error instanceof Error ? error.message : String(error) - }` - ) - ); - }, - process.platform === 'win32' ? 2500 : 1000 - ); - - // Defer non-critical startup work to avoid thread pool contention. - // The window is now visible and responsive; these run in the background. - scheduleStartupTask(() => { - void teamProvisioningService.warmup(); - teamDataService.startProcessHealthPolling(); - void schedulerService?.start(); - }, 5000); + rendererDidFinishLoad = true; + runPostRendererStartupTasks(); } }); @@ -2037,34 +2206,16 @@ function createWindow(): void { return; } markRendererUnavailable(mainWindow); + rendererDidFinishLoad = false; + fileWatcherStartupStarted = false; branchStatusService?.resetAllTracking(); - const activeContext = contextRegistry.getActive(); - activeContext?.stopFileWatcher(); + contextRegistry?.getActive()?.stopFileWatcher(); if (mainWindow) { scheduleRendererRecovery(mainWindow); } }); - // Set main window reference for notification manager and updater - if (notificationManager) { - notificationManager.setMainWindow(mainWindow); - } - if (updaterService) { - updaterService.setMainWindow(mainWindow); - } - if (cliInstallerService) { - cliInstallerService.setMainWindow(mainWindow); - } - setTmuxMainWindow(mainWindow); - if (ptyTerminalService) { - ptyTerminalService.setMainWindow(mainWindow); - } - if (teamProvisioningService) { - teamProvisioningService.setMainWindow(mainWindow); - } - codexAccountFeature?.setMainWindow(mainWindow); - setEditorMainWindow(mainWindow); - setReviewMainWindow(mainWindow); + attachMainWindowToServices(); logger.info('Main window created'); } @@ -2074,18 +2225,14 @@ function createWindow(): void { */ void app.whenReady().then(async () => { logger.info('App ready, initializing...'); - - // Pre-warm interactive shell env cache (non-blocking). - // On macOS, Finder-launched apps get a minimal PATH. This resolves the user's - // full shell PATH (nvm, homebrew, .local/bin, etc.) in the background so that - // CliInstallerService.getStatus() and other services get cached results instantly. - void resolveInteractiveShellEnv(); + registerAppStartupHandlers(); try { - // Initialize services first - await initializeServices(); + publishStartupStatus({ + phase: 'electron-ready', + message: 'Opening window...', + }); - // Apply configuration settings const config = configManager.getConfig(); // Sync Sentry telemetry opt-in flag from persisted config @@ -2109,9 +2256,19 @@ void app.whenReady().then(async () => { // so we avoid runtime setIcon calls that can fail and block startup. } - // Then create window createWindow(); + await initializeServices(); + servicesReady = true; + attachMainWindowToServices(); + publishStartupStatus({ + phase: 'ready', + message: 'Ready', + ready: true, + error: null, + }); + runPostRendererStartupTasks(); + // Listen for notification click events notificationManager.on('notification-clicked', (_error) => { if (isShutdownStarted()) { @@ -2124,6 +2281,12 @@ void app.whenReady().then(async () => { }); } catch (error) { logger.error('Startup initialization failed:', error); + publishStartupStatus({ + phase: 'failed', + message: 'Startup failed', + ready: false, + error: error instanceof Error ? error.message : String(error), + }); if (!mainWindow) { createWindow(); } diff --git a/src/main/ipc/rendererLogs.ts b/src/main/ipc/rendererLogs.ts index 589110d4..6b52a73f 100644 --- a/src/main/ipc/rendererLogs.ts +++ b/src/main/ipc/rendererLogs.ts @@ -10,6 +10,7 @@ const lastHeartbeatWarnedAtByWebContentsId = new Map(); const hasReceivedHeartbeatByWebContentsId = new Set(); let heartbeatMonitorStarted = false; let heartbeatMonitorInterval: ReturnType | null = null; +let rendererLogHandlersRegistered = false; function startHeartbeatMonitor(): void { if (heartbeatMonitorStarted) return; @@ -40,6 +41,10 @@ function startHeartbeatMonitor(): void { } export function registerRendererLogHandlers(ipcMain: IpcMain): void { + if (rendererLogHandlersRegistered) { + return; + } + rendererLogHandlersRegistered = true; startHeartbeatMonitor(); ipcMain.on(RENDERER_LOG, () => { @@ -69,6 +74,7 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void { ipcMain.removeAllListeners(RENDERER_LOG); ipcMain.removeAllListeners(RENDERER_BOOT); ipcMain.removeAllListeners(RENDERER_HEARTBEAT); + rendererLogHandlersRegistered = false; if (heartbeatMonitorInterval) { clearInterval(heartbeatMonitorInterval); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 9827ff3b..7ee2ce27 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -7,6 +7,23 @@ import * as path from 'path'; import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe'; import { getConfiguredCliFlavor } from './cliFlavor'; +export interface ClaudeBinaryResolveProgress { + phase: string; + message: string; +} + +export interface ClaudeBinaryResolveOptions { + onProgress?: (progress: ClaudeBinaryResolveProgress) => void; +} + +function emitProgress( + options: ClaudeBinaryResolveOptions | undefined, + phase: string, + message: string +): void { + options?.onProgress?.({ phase, message }); +} + async function isExecutable(filePath: string): Promise { if (process.platform === 'win32') { try { @@ -218,34 +235,46 @@ export class ClaudeBinaryResolver { cacheVerifiedAt = 0; } - static async resolve(): Promise { + static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise { if (cachedPath !== undefined) { const now = Date.now(); // Re-verify the cached binary still exists, but at most once per TTL if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) { + emitProgress(options, 'cache-verify', 'Verifying cached runtime...'); if (await isExecutable(cachedPath)) { cacheVerifiedAt = now; + emitProgress(options, 'cache-hit', 'Using cached runtime...'); return cachedPath; } cachedPath = undefined; cacheVerifiedAt = 0; // Fall through to full resolution below } else { + emitProgress( + options, + cachedPath ? 'cache-hit' : 'cache-miss', + 'Using cached runtime status...' + ); return cachedPath; } } if (!resolveInFlight) { - resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => { + resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => { resolveInFlight = null; }); + } else { + emitProgress(options, 'in-flight', 'Waiting for runtime lookup...'); } return resolveInFlight; } - private static async runResolve(): Promise { - await resolveInteractiveShellEnv(); + private static async runResolve(options: ClaudeBinaryResolveOptions): Promise { + await resolveInteractiveShellEnv({ + onProgress: (progress) => emitProgress(options, progress.phase, progress.message), + }); const enrichedPath = buildMergedCliPath(null); const flavor = getConfiguredCliFlavor(); + emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`); const overrideRaw = flavor === 'agent_teams_orchestrator' @@ -253,6 +282,7 @@ export class ClaudeBinaryResolver { process.env.CLAUDE_CLI_PATH?.trim()) : process.env.CLAUDE_CLI_PATH?.trim(); if (overrideRaw) { + emitProgress(options, 'configured-path', 'Checking configured runtime path...'); const looksLikePath = path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/'); const resolvedOverride = looksLikePath @@ -262,15 +292,18 @@ export class ClaudeBinaryResolver { if (resolvedOverride) { cachedPath = resolvedOverride; cacheVerifiedAt = Date.now(); + emitProgress(options, 'configured-path-found', 'Using configured runtime path...'); return cachedPath; } } if (flavor === 'agent_teams_orchestrator') { + emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); const bundledBinary = await resolveBundledOrchestratorBinary(); if (bundledBinary) { cachedPath = bundledBinary; cacheVerifiedAt = Date.now(); + emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); return cachedPath; } @@ -279,17 +312,21 @@ export class ClaudeBinaryResolver { // claude-multimodel on PATH without making this resolver guess a sibling // repo name or folder. const orchestratorBinaryName = 'claude-multimodel'; + emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...'); const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath); if (fromPath) { cachedPath = fromPath; cacheVerifiedAt = Date.now(); + emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...'); return cachedPath; } + emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...'); const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName); if (fromDoctor) { cachedPath = fromDoctor; cacheVerifiedAt = Date.now(); + emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...'); return cachedPath; } @@ -300,10 +337,12 @@ export class ClaudeBinaryResolver { } const baseBinaryName = 'claude'; + emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...'); const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath); if (fromPath) { cachedPath = fromPath; cacheVerifiedAt = Date.now(); + emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...'); return cachedPath; } @@ -343,7 +382,11 @@ export class ClaudeBinaryResolver { platformBinaryNames.map((name) => path.join(dir, name)) ); + emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...'); const nvmCandidates = await collectNvmCandidates(); + if (nvmCandidates.length > 0) { + emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...'); + } const allCandidates = [...candidates, ...nvmCandidates]; // Check all fallback candidates in parallel for speed @@ -358,17 +401,29 @@ export class ClaudeBinaryResolver { if (found) { cachedPath = found.path; cacheVerifiedAt = Date.now(); + emitProgress( + options, + 'fallback-location-found', + 'Using Claude CLI from install locations...' + ); return cachedPath; } + emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...'); const fromDoctor = await resolveFromDoctorFallback(baseBinaryName); if (fromDoctor) { cachedPath = fromDoctor; cacheVerifiedAt = Date.now(); + emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...'); return cachedPath; } // Don't cache null — CLI may be installed later without app restart + emitProgress( + options, + 'not-found', + 'Runtime not found. Continuing with limited launch support...' + ); return null; } } diff --git a/src/main/services/team/ProcessBootstrapTransportEvidence.ts b/src/main/services/team/ProcessBootstrapTransportEvidence.ts index 2d52b1c0..8b452e70 100644 --- a/src/main/services/team/ProcessBootstrapTransportEvidence.ts +++ b/src/main/services/team/ProcessBootstrapTransportEvidence.ts @@ -203,15 +203,27 @@ export function summarizeProcessBootstrapTransportEvents( export function buildProcessBootstrapPendingDiagnostic( summary: ProcessBootstrapTransportSummary ): string { + if (summary.submitted) { + return summary.lastStage + ? `Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: ${summary.lastStage}.` + : 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.'; + } + return summary.lastStage - ? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.` - : 'Bootstrap transport is waiting for bootstrap confirmation.'; + ? `Bootstrap prompt has not been submitted yet. Last transport stage: ${summary.lastStage}.` + : 'Bootstrap prompt has not been submitted yet.'; } export function buildProcessBootstrapTimeoutDiagnostic( summary: ProcessBootstrapTransportSummary ): string { + if (summary.submitted) { + return summary.lastStage + ? `Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}` + : 'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.'; + } + return summary.lastStage - ? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}` - : 'Teammate was registered but did not bootstrap-confirm before timeout.'; + ? `Bootstrap prompt was not submitted before timeout. Last transport stage: ${summary.lastStage}` + : 'Bootstrap prompt was not submitted before timeout.'; } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 32c19341..d34e856a 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -16,6 +16,15 @@ export interface McpLaunchSpec { args: string[]; } +export interface McpLaunchSpecResolveProgress { + phase: string; + message: string; +} + +export interface McpLaunchSpecResolveOptions { + onProgress?: (progress: McpLaunchSpecResolveProgress) => void; +} + const MCP_SERVER_NAME = 'agent-teams'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const logger = createLogger('Service:TeamMcpConfigBuilder'); @@ -158,15 +167,24 @@ export function clearResolvedNodePathForTests(): void { _resolvedNodePath = undefined; } +function emitProgress( + options: McpLaunchSpecResolveOptions | undefined, + phase: string, + message: string +): void { + options?.onProgress?.({ phase, message }); +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. * Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency). */ -async function resolveNodePath(): Promise { +async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { if (_resolvedNodePath) return _resolvedNodePath; try { + emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); const resolved = await new Promise((resolve, reject) => { execFile( 'node', @@ -180,12 +198,14 @@ async function resolveNodePath(): Promise { }); if (resolved) { _resolvedNodePath = resolved; + emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); return _resolvedNodePath; } } catch { // node not found or timed out — use bare 'node' and let the OS resolve it } _resolvedNodePath = 'node'; + emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...'); return _resolvedNodePath; } @@ -199,10 +219,11 @@ async function resolveNodePath(): Promise { * * Returns the resolved index.js path (stable copy or resourcesPath fallback). */ -async function resolvePackagedServerEntry(): Promise { +async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise { const fallbackEntry = getPackagedServerEntry(); if (!isPackagedApp()) return fallbackEntry; + emitProgress(options, 'packaged-server', 'Checking packaged MCP server...'); const appVersion = getAppVersion(); const baseDir = getMcpServerBasePath(); const finalDir = path.join(baseDir, appVersion); @@ -210,6 +231,7 @@ async function resolvePackagedServerEntry(): Promise { // Reuse existing valid copy if (await hasValidServerCopy(finalDir)) { + emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...'); return finalEntry; } @@ -230,6 +252,7 @@ async function resolvePackagedServerEntry(): Promise { return fallbackEntry; } + emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...'); // Atomic: copy to temp dir, then rename to final const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`); await fs.promises.mkdir(tmpDir, { recursive: true }); @@ -254,6 +277,7 @@ async function resolvePackagedServerEntry(): Promise { } logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`); + emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...'); return finalEntry; } catch (error) { logger.warn( @@ -265,16 +289,18 @@ async function resolvePackagedServerEntry(): Promise { } } -export async function resolveAgentTeamsMcpLaunchSpec(): Promise { +export async function resolveAgentTeamsMcpLaunchSpec( + options: McpLaunchSpecResolveOptions = {} +): Promise { const checked: string[] = []; // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath if (isPackagedApp()) { - const packagedEntry = await resolvePackagedServerEntry(); + const packagedEntry = await resolvePackagedServerEntry(options); checked.push(packagedEntry); if (await pathExists(packagedEntry)) { return { - command: await resolveNodePath(), + command: await resolveNodePath(options), args: [packagedEntry], }; } @@ -283,12 +309,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { // 2. Dev mode — prefer source so pnpm dev always sees current MCP tools const sourceEntry = getSourceServerEntry(); + emitProgress(options, 'source-entry', 'Checking MCP source entry...'); checked.push(sourceEntry); if (await pathExists(sourceEntry)) { + emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...'); const tsxCli = await resolveWorkspaceTsxCli(checked); if (tsxCli) { return { - command: await resolveNodePath(), + command: await resolveNodePath(options), args: [tsxCli, sourceEntry], }; } @@ -296,10 +324,11 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { // 3. Dev mode fallback — use built dist when source execution is unavailable const builtEntry = getBuiltServerEntry(); + emitProgress(options, 'built-entry', 'Checking built MCP server entry...'); checked.push(builtEntry); if (await pathExists(builtEntry)) { return { - command: await resolveNodePath(), + command: await resolveNodePath(options), args: [builtEntry], }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fbd59f6d..ae69dbfe 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -42,9 +42,9 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { isPathWithinRoot } from '@main/utils/pathValidation'; import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; -import { isPathWithinRoot } from '@main/utils/pathValidation'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { @@ -157,15 +157,6 @@ import { parseBootstrapRuntimeProofDetail, validateBootstrapRuntimeProofEnvelope, } from './bootstrap/BootstrapProofValidation'; -import { - buildProcessBootstrapPendingDiagnostic, - buildProcessBootstrapTimeoutDiagnostic, - deriveProcessTransportProjectionPhase, - sanitizeProcessRuntimeEventFilePrefix, - summarizeProcessBootstrapTransportEvents, - type ProcessBootstrapTransportEvent, - type ProcessBootstrapTransportSummary, -} from './ProcessBootstrapTransportEvidence'; import { buildNativeAppManagedBootstrapSpecs, type NativeAppManagedBootstrapSpec, @@ -247,6 +238,15 @@ import { } from './idleNotificationMainProcessSemantics'; import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; +import { + buildProcessBootstrapPendingDiagnostic, + buildProcessBootstrapTimeoutDiagnostic, + deriveProcessTransportProjectionPhase, + type ProcessBootstrapTransportEvent, + type ProcessBootstrapTransportSummary, + sanitizeProcessRuntimeEventFilePrefix, + summarizeProcessBootstrapTransportEvents, +} from './ProcessBootstrapTransportEvidence'; import { boundLaunchDiagnostics, buildProgressLiveOutput, @@ -289,6 +289,7 @@ import { sanitizeProcessCommandForDiagnostics, } from './TeamRuntimeLivenessResolver'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; +import { TeamTaskActivityIntervalService } from './TeamTaskActivityIntervalService'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; @@ -5451,6 +5452,8 @@ export class TeamProvisioningService { | null = null; private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; + private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); + private readonly crashRepairedActivityIntervalsByTeam = new Set(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -5504,6 +5507,15 @@ export class TeamProvisioningService { this.scheduleStaleAnthropicTeamApiKeyHelperCleanup(); } + private repairStaleTaskActivityIntervalsOnce( + teamName: string, + launchSnapshot?: PersistedTeamLaunchSnapshot | null + ): void { + if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return; + this.taskActivityIntervalService.repairStaleIntervalsAfterCrash(teamName, launchSnapshot); + this.crashRepairedActivityIntervalsByTeam.add(teamName); + } + private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void { void cleanupStaleAnthropicTeamApiKeyHelpers({ baseClaudeDir: getClaudeBasePath(), @@ -12255,6 +12267,20 @@ export class TeamProvisioningService { return; } + if (prev.runtimeAlive === true && next.runtimeAlive !== true) { + this.taskActivityIntervalService.pauseActiveIntervalsForMember( + run.teamName, + memberName, + updatedAt + ); + } else if (prev.runtimeAlive !== true && next.runtimeAlive === true) { + this.taskActivityIntervalService.resumeActiveIntervalsForMember( + run.teamName, + memberName, + updatedAt + ); + } + run.memberSpawnStatuses.set(memberName, next); if ( (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || @@ -12394,10 +12420,21 @@ export class TeamProvisioningService { }> { const readPersistedStatuses = async (resolvedRunId: string | null) => { const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); + this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot); const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, { openCodeSecondaryBootstrapPendingMembers: this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), }); + const runtimeObservedAt = nowIso(); + for (const [memberName, entry] of Object.entries(nextStatuses)) { + if (entry.runtimeAlive === true) { + this.taskActivityIntervalService.resumeActiveIntervalsForMember( + teamName, + memberName, + runtimeObservedAt + ); + } + } const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const summary = expectedMembers ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) @@ -16886,6 +16923,10 @@ export class TeamProvisioningService { if (existingProvisioningRunId) { return { runId: existingProvisioningRunId }; } + const previousLaunchSnapshot = await this.launchStateStore + .read(request.teamName) + .catch(() => null); + this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot); const stopAllGenerationAtStart = this.stopAllTeamsGeneration; assertAppDeterministicBootstrapEnabled(); if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { @@ -25806,6 +25847,7 @@ export class TeamProvisioningService { */ async stopTeam(teamName: string): Promise { this.invalidateRuntimeSnapshotCaches(teamName); + this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -26301,6 +26343,7 @@ export class TeamProvisioningService { if (orphanOnly.length > 0) { logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`); for (const teamName of orphanOnly) { + this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName); this.stopPersistentTeamMembers(teamName); await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); } @@ -30828,7 +30871,14 @@ export class TeamProvisioningService { return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode'; }); if (configHasOpenCodeMember) { - return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId); + return this.buildConfigLaunchCompatibilityReport( + teamName, + configMembers, + leadProviderId, + { + ignoredInboxNames: true, + } + ); } const configMembersByName = new Map( configMembers.map((member) => [member.name.toLowerCase(), member] as const) @@ -30915,7 +30965,8 @@ export class TeamProvisioningService { private buildConfigLaunchCompatibilityReport( teamName: string, configMembers: TeamCreateRequest['members'], - leadProviderId?: TeamProviderId + leadProviderId?: TeamProviderId, + options: { ignoredInboxNames?: boolean } = {} ): TeamLaunchCompatibilityReport { if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) { return { @@ -30957,8 +31008,10 @@ export class TeamProvisioningService { rosterSource: 'config', members: configMembers, warnings: [ - 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' + - 'Run a fresh team bootstrap to persist stable member metadata.', + options.ignoredInboxNames + ? 'members.meta.json is missing; launch used complete config.json member metadata instead of inbox fallback to preserve mixed provider/model layout.' + : 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' + + 'Run a fresh team bootstrap to persist stable member metadata.', ], blockers: [], repairAction: 'materialize-members-meta', diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts new file mode 100644 index 00000000..70f937ad --- /dev/null +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -0,0 +1,269 @@ +import { getTasksBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { TeamTaskReader } from './TeamTaskReader'; + +import type { + PersistedTeamLaunchMemberState, + PersistedTeamLaunchSnapshot, + TaskReviewInterval, + TeamTask, +} from '@shared/types'; + +interface ActivityIntervalResult { + changedTasks: number; +} + +type MutableTeamTask = TeamTask & { + reviewIntervals?: TaskReviewInterval[]; +}; + +const CRASH_REPAIR_GRACE_MS = 5_000; + +function normalizeMemberName(value: string | null | undefined): string { + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; +} + +function parseIsoMs(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toIso(ms: number): string { + return new Date(ms).toISOString(); +} + +function ensureCloseIso(startedAt: string, at: string): string { + const startedAtMs = parseIsoMs(startedAt); + const atMs = parseIsoMs(at); + if (startedAtMs <= 0) return at; + if (atMs <= startedAtMs) return toIso(startedAtMs); + return toIso(atMs); +} + +function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string { + const startedAtMs = parseIsoMs(startedAt); + const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now(); + const evidenceMs = Math.max( + parseIsoMs(member?.lastHeartbeatAt), + parseIsoMs(member?.runtimeLastSeenAt), + parseIsoMs(member?.lastRuntimeAliveAt) + ); + const closeMs = + evidenceMs > 0 + ? Math.max(safeStartedAtMs, evidenceMs + CRASH_REPAIR_GRACE_MS) + : safeStartedAtMs + CRASH_REPAIR_GRACE_MS; + const boundedCloseMs = Math.max(safeStartedAtMs, Math.min(Date.now(), closeMs)); + return toIso(boundedCloseMs); +} + +function hasOpenWorkInterval(task: MutableTeamTask): boolean { + return ( + Array.isArray(task.workIntervals) && + task.workIntervals.some((interval) => !interval.completedAt) + ); +} + +function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean { + const reviewerKey = normalizeMemberName(reviewer); + return ( + Array.isArray(task.reviewIntervals) && + task.reviewIntervals.some( + (interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey + ) + ); +} + +function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: string): boolean { + if (!Array.isArray(task.workIntervals)) return false; + if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false; + + let changed = false; + task.workIntervals = task.workIntervals.map((interval) => { + if (interval.completedAt) return interval; + changed = true; + return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) }; + }); + return changed; +} + +function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: string): boolean { + if (!Array.isArray(task.reviewIntervals)) return false; + const reviewerKey = normalizeMemberName(reviewer); + + let changed = false; + task.reviewIntervals = task.reviewIntervals.map((interval) => { + if (interval.completedAt) return interval; + if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval; + changed = true; + return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) }; + }); + return changed; +} + +function getActiveReviewActor(task: MutableTeamTask): string | null { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'review_started') { + return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null; + } + if ( + event.type === 'review_approved' || + event.type === 'review_changes_requested' || + (event.type === 'status_changed' && + (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) || + event.type === 'task_created' + ) { + return null; + } + } + return null; +} + +function readTaskFile(filePath: string): MutableTeamTask | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; + } catch { + return null; + } +} + +function writeTaskFile(filePath: string, task: MutableTeamTask): void { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(task, null, 2)); + fs.renameSync(tempPath, filePath); +} + +export class TeamTaskActivityIntervalService { + private mutateTeamTasks( + teamName: string, + mutate: (task: MutableTeamTask) => boolean + ): ActivityIntervalResult { + const tasksDir = path.join(getTasksBasePath(), teamName); + let entries: string[]; + try { + entries = fs.readdirSync(tasksDir); + } catch { + return { changedTasks: 0 }; + } + + let changedTasks = 0; + for (const fileName of entries) { + if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; + const filePath = path.join(tasksDir, fileName); + const task = readTaskFile(filePath); + if (!task) continue; + if (!mutate(task)) continue; + writeTaskFile(filePath, task); + changedTasks += 1; + } + + if (changedTasks > 0) { + TeamTaskReader.invalidateAllTasksCache(); + } + return { changedTasks }; + } + + pauseActiveIntervalsForTeam( + teamName: string, + at = new Date().toISOString() + ): ActivityIntervalResult { + return this.mutateTeamTasks(teamName, (task) => { + const changedWork = closeOpenWorkIntervals(task, at); + const changedReview = closeOpenReviewIntervals(task, at); + return changedWork || changedReview; + }); + } + + pauseActiveIntervalsForMember( + teamName: string, + memberName: string, + at = new Date().toISOString() + ): ActivityIntervalResult { + return this.mutateTeamTasks(teamName, (task) => { + const changedWork = closeOpenWorkIntervals(task, at, memberName); + const changedReview = closeOpenReviewIntervals(task, at, memberName); + return changedWork || changedReview; + }); + } + + resumeActiveIntervalsForMember( + teamName: string, + memberName: string, + at = new Date().toISOString() + ): ActivityIntervalResult { + const memberKey = normalizeMemberName(memberName); + if (!memberKey) return { changedTasks: 0 }; + + return this.mutateTeamTasks(teamName, (task) => { + let changed = false; + + if ( + task.status === 'in_progress' && + normalizeMemberName(task.owner) === memberKey && + !hasOpenWorkInterval(task) + ) { + task.workIntervals = [ + ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), + { startedAt: at }, + ]; + changed = true; + } + + const activeReviewer = getActiveReviewActor(task); + if ( + activeReviewer && + normalizeMemberName(activeReviewer) === memberKey && + !hasOpenReviewInterval(task, activeReviewer) + ) { + task.reviewIntervals = [ + ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), + { reviewer: activeReviewer, startedAt: at }, + ]; + changed = true; + } + + return changed; + }); + } + + repairStaleIntervalsAfterCrash( + teamName: string, + launchSnapshot?: PersistedTeamLaunchSnapshot | null + ): ActivityIntervalResult { + const memberByName = new Map(); + for (const member of Object.values(launchSnapshot?.members ?? {})) { + memberByName.set(normalizeMemberName(member.name), member); + } + + return this.mutateTeamTasks(teamName, (task) => { + let changed = false; + if (Array.isArray(task.workIntervals)) { + const ownerMember = memberByName.get(normalizeMemberName(task.owner)); + task.workIntervals = task.workIntervals.map((interval) => { + if (interval.completedAt) return interval; + changed = true; + return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) }; + }); + } + + if (Array.isArray(task.reviewIntervals)) { + task.reviewIntervals = task.reviewIntervals.map((interval) => { + if (interval.completedAt) return interval; + const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer)); + changed = true; + return { + ...interval, + completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember), + }; + }); + } + + return changed; + }); + } +} diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 50a3d169..3edf2ca5 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -15,6 +15,7 @@ import type { TaskComment, TaskHistoryEvent, TaskRef, + TaskReviewInterval, TaskWorkInterval, TeamTask, } from '@shared/types'; @@ -194,6 +195,25 @@ export class TeamTaskReader { completedAt: i.completedAt, })) : undefined; + const reviewIntervals: TaskReviewInterval[] | undefined = Array.isArray( + parsed.reviewIntervals + ) + ? (parsed.reviewIntervals as unknown[]) + .filter( + (i): i is { reviewer: string; startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).reviewer === 'string' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) + .map((i) => ({ + reviewer: i.reviewer, + startedAt: i.startedAt, + completedAt: i.completedAt, + })) + : undefined; const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( parsed.status as TeamTask['status'] ) @@ -223,6 +243,7 @@ export class TeamTaskReader { createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status, workIntervals, + reviewIntervals, historyEvents, blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') diff --git a/src/main/utils/electronUserDataMigration.ts b/src/main/utils/electronUserDataMigration.ts index fb9db857..d09fc9dc 100644 --- a/src/main/utils/electronUserDataMigration.ts +++ b/src/main/utils/electronUserDataMigration.ts @@ -2,10 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; const LEGACY_USER_DATA_DIR_NAMES = [ - 'Claude Agent Teams UI', - 'claude-agent-teams-ui', 'agent-teams-ai', 'Agent Teams UI', + 'Claude Agent Teams UI', + 'claude-agent-teams-ui', 'claude-devtools', 'claude-code-context', ] as const; @@ -72,6 +72,9 @@ const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([ 'Trust Tokens-journal', ]); +const DURABLE_USER_DATA_ROOT_NAMES = new Set(['data', 'backups']); +const PREFERRED_USER_DATA_DIR_NAME = 'agent-teams-ai'; + const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000; export function getLegacyElectronUserDataCandidates(currentPath: string): string[] { @@ -104,6 +107,30 @@ export function migrateElectronUserDataDirectory( }; } + const preferredExistingPath = selectPreferredElectronUserDataPath(currentPath); + if (preferredExistingPath) { + try { + setLegacyElectronPaths(app, preferredExistingPath, logger); + logger?.info(`Reusing preferred Electron userData at ${preferredExistingPath}`); + return { + currentPath, + legacyPath: preferredExistingPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }; + } catch (error) { + logger?.warn(`Electron userData preferred reuse failed: ${stringifyError(error)}`); + return { + currentPath, + legacyPath: preferredExistingPath, + migrated: false, + fallbackToLegacy: false, + reason: 'error', + }; + } + } + if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) { return { currentPath, @@ -209,15 +236,23 @@ export function migrateElectronUserDataDirectory( } function selectLegacyElectronUserDataPath(currentPath: string): string | null { - const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists); return ( - candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? - candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? - candidates[0] ?? - null + getLegacyElectronUserDataCandidates(currentPath) + .filter(directoryExists) + .find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? null ); } +function selectPreferredElectronUserDataPath(currentPath: string): string | null { + const preferredPath = path.join(path.dirname(currentPath), PREFERRED_USER_DATA_DIR_NAME); + if (path.resolve(preferredPath) === path.resolve(currentPath)) { + return null; + } + return directoryExists(preferredPath) && directoryHasDurableUserDataEntries(preferredPath) + ? preferredPath + : null; +} + function setLegacyElectronPaths( app: ElectronUserDataMigrationApp, legacyPath: string, @@ -252,7 +287,7 @@ function copyLegacyUserDataDirectory( copyDirectory(legacyPath, tempPath); - if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) { + if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) { fs.rmdirSync(currentPath); } @@ -360,9 +395,9 @@ function directoryExists(targetPath: string): boolean { } } -function directoryHasEntries(targetPath: string): boolean { +function directoryIsEmpty(targetPath: string): boolean { try { - return fs.readdirSync(targetPath).length > 0; + return fs.readdirSync(targetPath).length === 0; } catch { return false; } @@ -381,6 +416,12 @@ function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath: for (const entry of entries) { const entryPath = path.join(targetPath, entry.name); + const relativePath = path.relative(rootPath, entryPath); + const rootSegment = relativePath.split(path.sep).find(Boolean); + if (!rootSegment || !DURABLE_USER_DATA_ROOT_NAMES.has(rootSegment)) { + continue; + } + if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) { continue; } diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 13a8c7cd..2cec03a4 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -20,6 +20,23 @@ const SHELL_ENV_TIMEOUT_MS = 12_000; let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; let shellEnvResolvePromise: Promise | null = null; +export interface ShellEnvResolveProgress { + phase: string; + message: string; +} + +export interface ShellEnvResolveOptions { + onProgress?: (progress: ShellEnvResolveProgress) => void; +} + +function emitProgress( + options: ShellEnvResolveOptions | undefined, + phase: string, + message: string +): void { + options?.onProgress?.({ phase, message }); +} + function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { const parsed: NodeJS.ProcessEnv = {}; const lines = content.split('\0'); @@ -95,14 +112,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise { +export async function resolveInteractiveShellEnv( + options: ShellEnvResolveOptions = {} +): Promise { if (cachedInteractiveShellEnv) { + emitProgress(options, 'shell-env-cached', 'Using cached shell environment...'); return cachedInteractiveShellEnv; } if (shellEnvResolvePromise) { + emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...'); return shellEnvResolvePromise; } if (process.platform === 'win32') { + emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...'); cachedInteractiveShellEnv = {}; return cachedInteractiveShellEnv; } @@ -110,6 +132,7 @@ export async function resolveInteractiveShellEnv(): Promise { shellEnvResolvePromise = (async () => { const shellPath = process.env.SHELL || '/bin/zsh'; try { + emitProgress(options, 'shell-env-login', 'Reading login shell environment...'); const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); cachedInteractiveShellEnv = loginEnv; return loginEnv; @@ -117,6 +140,7 @@ export async function resolveInteractiveShellEnv(): Promise { const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); logger.warn(`Failed to resolve login shell env: ${loginMessage}`); try { + emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...'); const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); cachedInteractiveShellEnv = interactiveEnv; return interactiveEnv; @@ -124,6 +148,7 @@ export async function resolveInteractiveShellEnv(): Promise { const interactiveMessage = interactiveError instanceof Error ? interactiveError.message : String(interactiveError); logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); + emitProgress(options, 'shell-env-fallback', 'Using current process environment...'); return {}; } } finally { diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index f471ecdb..fec65057 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -220,6 +220,7 @@ interface ParsedTask { reviewState?: unknown; metadata?: { _internal?: unknown }; workIntervals?: unknown; + reviewIntervals?: unknown; historyEvents?: unknown; attachments?: unknown; sourceMessageId?: unknown; @@ -231,6 +232,12 @@ interface RawWorkInterval { completedAt?: unknown; } +interface RawReviewInterval { + reviewer?: unknown; + startedAt?: unknown; + completedAt?: unknown; +} + interface RawHistoryEvent { id?: unknown; type?: unknown; @@ -1215,6 +1222,27 @@ function normalizeWorkIntervals( })); } +function normalizeReviewIntervals( + parsed: ParsedTask +): { reviewer: string; startedAt: string; completedAt?: string }[] | undefined { + if (!Array.isArray(parsed.reviewIntervals)) return undefined; + return (parsed.reviewIntervals as unknown[]) + .filter( + (i): i is RawReviewInterval => + Boolean(i) && + typeof i === 'object' && + typeof (i as RawReviewInterval).reviewer === 'string' && + typeof (i as RawReviewInterval).startedAt === 'string' && + ((i as RawReviewInterval).completedAt === undefined || + typeof (i as RawReviewInterval).completedAt === 'string') + ) + .map((i) => ({ + reviewer: i.reviewer as string, + startedAt: i.startedAt as string, + completedAt: i.completedAt as string | undefined, + })); +} + function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined { if (!Array.isArray(parsed.historyEvents)) return undefined; return (parsed.historyEvents as unknown[]) @@ -1479,6 +1507,7 @@ async function readTasksDirForTeam( createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status, workIntervals: normalizeWorkIntervals(parsed), + reviewIntervals: normalizeReviewIntervals(parsed), historyEvents: normalizeHistoryEvents(parsed), blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index c32faa37..3ac18cad 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -17,6 +17,12 @@ export const RENDERER_BOOT = 'renderer:boot'; /** Renderer -> main heartbeat (detect renderer stalls) */ export const RENDERER_HEARTBEAT = 'renderer:heartbeat'; +/** Renderer -> main startup status request */ +export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus'; + +/** Main -> renderer startup progress update */ +export const APP_STARTUP_PROGRESS = 'appStartup:progress'; + // ============================================================================= // Config API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 3880e5d6..4db3fa60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,8 @@ import { API_KEYS_SAVE, API_KEYS_STORAGE_STATUS, APP_RELAUNCH, + APP_STARTUP_GET_STATUS, + APP_STARTUP_PROGRESS, CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, @@ -249,6 +251,7 @@ import type { AppConfig, ApplyReviewRequest, ApplyReviewResult, + AppStartupStatus, AttachmentFileData, BoardTaskActivityDetailResult, BoardTaskActivityEntry, @@ -480,6 +483,18 @@ const electronAPI: ElectronAPI = { runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), memberLogStream: createMemberLogStreamBridge(), + startup: { + getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise, + onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: AppStartupStatus): void => { + callback(status); + }; + ipcRenderer.on(APP_STARTUP_PROGRESS, listener); + return (): void => { + ipcRenderer.removeListener(APP_STARTUP_PROGRESS, listener); + }; + }, + }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 9a1e0567..c0307cbd 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -895,7 +895,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { key={team.teamName} role="button" tabIndex={0} - className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-l-[3px] border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]" + className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]" style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined} onClick={() => openTeamTab(team.teamName, team.projectPath)} onKeyDown={(e) => { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index aaecc8b3..f1ce30ee 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1380,6 +1380,18 @@ export const CreateTeamDialog = ({ [request, launchTeam] ); const modelValidationError = useMemo(() => { + if (selectedProviderId === 'opencode') { + if (!selectedModel.trim()) { + return 'OpenCode lead requires a selected model.'; + } + const activeMemberCount = soloTeam + ? 0 + : effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length; + if (activeMemberCount === 0) { + return 'OpenCode lead requires at least one OpenCode teammate.'; + } + } + const leadError = getTeamModelSelectionError( selectedProviderId, selectedModel, @@ -1409,7 +1421,13 @@ export const CreateTeamDialog = ({ } return null; - }, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]); + }, [ + effectiveMemberDrafts, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + soloTeam, + ]); const leadModelIssueText = useMemo(() => { const issue = getProvisioningModelIssue( prepareChecks, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index ad2db8c5..3f6bf8c2 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -138,8 +138,8 @@ import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibili import { computeEffectiveTeamModel, formatTeamModelSummary, - OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, - OPENCODE_TEAM_LEAD_DISABLED_REASON, + OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL, + OPENCODE_ONE_SHOT_DISABLED_REASON, TeamModelSelector, } from './TeamModelSelector'; import { @@ -249,6 +249,14 @@ function getStoredTeamProvider(): TeamProviderId { return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true); } +function normalizeOneShotProviderForMode( + providerId: TeamProviderId | undefined, + multimodelEnabled: boolean +): TeamProviderId { + const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled); + return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId; +} + function getStoredTeamModel(providerId: TeamProviderId): string { const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); if (stored === null) { @@ -412,10 +420,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [isSubmitting, setIsSubmitting] = useState(false); const [selectedProviderId, setSelectedProviderIdRaw] = useState(() => - normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) + isLaunchMode + ? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) + : normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled) ); const [selectedModel, setSelectedModelRaw] = useState(() => - getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)) + getStoredTeamModel( + isLaunchMode + ? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) + : normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ) ); const [membersDrafts, setMembersDrafts] = useState([]); const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false); @@ -623,7 +637,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedProviderId = (value: TeamProviderId): void => { - const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled); + const normalizedValue = isLaunchMode + ? normalizeLeadProviderForMode(value, multimodelEnabled) + : normalizeOneShotProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); localStorage.setItem('team:lastSelectedProvider', normalizedValue); if (normalizedValue !== 'anthropic') { @@ -736,15 +752,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); - const scheduleProviderId = normalizeLeadProviderForMode( + const scheduleProviderId = normalizeOneShotProviderForMode( schedule.launchConfig.providerId, multimodelEnabled ); + const scheduleSourceProviderId = normalizeOptionalTeamProviderId( + schedule.launchConfig.providerId + ); setSelectedProviderIdRaw(scheduleProviderId); setSelectedModelRaw( - schedule.launchConfig.providerId !== 'gemini' && + scheduleSourceProviderId !== 'gemini' && + scheduleSourceProviderId !== 'opencode' && scheduleProviderId === - normalizeLeadProviderForMode(schedule.launchConfig.providerId, true) + normalizeOneShotProviderForMode(schedule.launchConfig.providerId, true) ? (schedule.launchConfig.model ?? '') : getStoredTeamModel('anthropic') ); @@ -765,7 +785,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - const storedProviderId = normalizeLeadProviderForMode( + const storedProviderId = normalizeOneShotProviderForMode( getStoredTeamProvider(), multimodelEnabled ); @@ -1825,6 +1845,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen cronExpression, ]); const modelValidationError = useMemo(() => { + if (isLaunchMode && selectedProviderId === 'opencode') { + if (!selectedModel.trim()) { + return 'OpenCode lead requires a selected model.'; + } + const activeMemberCount = effectiveMemberDrafts.filter( + (member) => !member.removedAt && member.name.trim() + ).length; + if (activeMemberCount === 0) { + return 'OpenCode lead requires at least one OpenCode teammate.'; + } + } + const leadError = getTeamModelSelectionError( selectedProviderId, selectedModel, @@ -2674,10 +2706,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen id="dialog-model" disableGeminiOption={isGeminiUiFrozen()} providerDisabledReasonById={{ - opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON, + opencode: OPENCODE_ONE_SHOT_DISABLED_REASON, }} providerDisabledBadgeLabelById={{ - opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, + opencode: OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL, }} /> ({ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default', getTeamProviderLabel: (providerId: string) => providerId, - OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane', - OPENCODE_TEAM_LEAD_DISABLED_REASON: - 'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.', TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'), })); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 4ec3fac9..7b75b9f5 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -6,8 +6,6 @@ import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitCon import { getProviderScopedTeamModelLabel, getTeamProviderLabel, - OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, - OPENCODE_TEAM_LEAD_DISABLED_REASON, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -151,8 +149,6 @@ export const LeadModelRow = ({ onValueChange={onModelChange} id="lead-model" disableGeminiOption={disableGeminiOption} - providerDisabledReasonById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON }} - providerDisabledBadgeLabelById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL }} modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} /> 0 ? normalized : null; -} - function getLaunchFailureLinkLabel(url: string): string { try { const parsed = new URL(url); @@ -180,6 +172,8 @@ export const MemberCard = memo(function MemberCard({ spawnRuntimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, + spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt, + spawnUpdatedAt: spawnEntry?.updatedAt, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, @@ -197,6 +191,7 @@ export const MemberCard = memo(function MemberCard({ const launchStatusLabel = launchPresentation.launchStatusLabel; const displayPresenceLabel = launchVisualState === 'queued' || + launchVisualState === 'starting_stale' || launchVisualState === 'bootstrap_stalled' || launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' || @@ -243,6 +238,7 @@ export const MemberCard = memo(function MemberCard({ (presenceLabel === 'starting' || presenceLabel === 'connecting' || launchVisualState === 'queued' || + launchVisualState === 'starting_stale' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || @@ -289,7 +285,7 @@ export const MemberCard = memo(function MemberCard({ spawnEntry?.runtimeDiagnostic ?? spawnEntry?.error; const launchFailureReason = showFailedLaunchBadge - ? normalizeLaunchFailureReason(rawLaunchFailureReason) + ? normalizeMemberLaunchFailureReason(rawLaunchFailureReason) : null; const hasLiveLaunchControls = isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; @@ -523,10 +519,17 @@ export const MemberCard = memo(function MemberCard({ className="flex shrink-0 items-center gap-1" title={runtimeEntry?.runtimeDiagnostic} > - + {launchVisualState === 'starting_stale' ? ( + + ) : ( + + )} Promise | void; updatingRole?: boolean; @@ -58,6 +60,8 @@ export const MemberDetailHeader = ({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnFirstSpawnAcceptedAt, + spawnUpdatedAt, isLaunchSettling, onUpdateRole, updatingRole, @@ -85,6 +89,8 @@ export const MemberDetailHeader = ({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnFirstSpawnAcceptedAt, + spawnUpdatedAt, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, @@ -102,7 +108,8 @@ export const MemberDetailHeader = ({ const badgeLabel = runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel ? runtimeAdvisoryLabel - : launchVisualState === 'bootstrap_stalled' || + : launchVisualState === 'starting_stale' || + launchVisualState === 'bootstrap_stalled' || launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 81402d29..ae9afc7f 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -159,6 +159,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({ spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapStalled: spawnEntry?.bootstrapStalled, + spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt, + spawnUpdatedAt: spawnEntry?.updatedAt, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, @@ -176,7 +178,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({ const badgeLabel = runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel ? runtimeAdvisoryLabel - : launchVisualState === 'bootstrap_stalled' || + : launchVisualState === 'starting_stale' || + launchVisualState === 'bootstrap_stalled' || launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 0368b32e..93434952 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -122,6 +122,24 @@ function areTaskWorkIntervalsEquivalent( }); } +function areTaskReviewIntervalsEquivalent( + left: TeamTaskWithKanban['reviewIntervals'], + right: TeamTaskWithKanban['reviewIntervals'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + return left.every((interval, index) => { + const other = right[index]; + if (!other) return false; + return ( + interval.reviewer === other.reviewer && + interval.startedAt === other.startedAt && + interval.completedAt === other.completedAt + ); + }); +} + function areTaskHistoryEventsEquivalent( left: TeamTaskWithKanban['historyEvents'], right: TeamTaskWithKanban['historyEvents'] @@ -166,6 +184,7 @@ function areMemberTaskMapsEquivalent( leftTask.reviewState !== rightTask.reviewState || leftTask.kanbanColumn !== rightTask.kanbanColumn || !areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) || + !areTaskReviewIntervalsEquivalent(leftTask.reviewIntervals, rightTask.reviewIntervals) || !areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents) ) { return false; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 46b0a402..5c52dfa8 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -114,8 +114,7 @@ export function normalizeLeadProviderForMode( providerId: TeamProviderId | undefined, multimodelEnabled: boolean ): TeamProviderId { - const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled); - return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId; + return normalizeProviderForMode(providerId, multimodelEnabled); } export function normalizeMemberDraftForProviderMode( diff --git a/src/renderer/index.html b/src/renderer/index.html index 4c170b55..33d141f0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -237,11 +237,155 @@ animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards; will-change: clip-path; } + #splash-status-row { + display: flex; + width: min(320px, 78vw); + margin-top: 14px; + align-items: baseline; + justify-content: center; + gap: 6px; + } + #splash-status { + min-height: 16px; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 12px; + font-weight: 500; + color: rgba(212, 212, 216, 0.72); + line-height: 1.35; + overflow-wrap: anywhere; + } + #splash-elapsed::before { + content: '·'; + margin-right: 6px; + color: rgba(212, 212, 216, 0.34); + } + #splash-elapsed { + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 11px; + font-weight: 500; + color: rgba(212, 212, 216, 0.5); + white-space: nowrap; + } + #splash-hint { + width: min(320px, 78vw); + min-height: 15px; + margin-top: 6px; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 11px; + color: rgba(212, 212, 216, 0.54); + line-height: 1.35; + } + #splash-timeline { + display: flex; + width: min(320px, 78vw); + max-height: 128px; + margin-top: 14px; + flex-direction: column; + gap: 6px; + overflow: hidden; + } + .splash-step { + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + opacity: 0.56; + } + .splash-step.is-current { + opacity: 1; + } + .splash-step-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: rgba(212, 212, 216, 0.42); + } + .splash-step.is-current .splash-step-dot { + background: #a78bfa; + box-shadow: 0 0 12px rgba(167, 139, 250, 0.55); + } + .splash-step-label, + .splash-step-time { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 11px; + line-height: 1.25; + } + .splash-step-label { + min-width: 0; + overflow: hidden; + color: rgba(212, 212, 216, 0.62); + text-overflow: ellipsis; + white-space: nowrap; + } + .splash-step.is-current .splash-step-label { + color: rgba(244, 244, 245, 0.86); + } + .splash-step-time { + color: rgba(212, 212, 216, 0.44); + white-space: nowrap; + } + #splash-progress { + position: relative; + width: min(240px, 64vw); + height: 3px; + margin-top: 10px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + } + #splash-progress-bar { + position: absolute; + inset: 0 auto 0 0; + width: 38%; + border-radius: inherit; + background: linear-gradient(90deg, #818cf8, #c084fc); + animation: splash-progress 1.2s ease-in-out infinite; + } + #splash.splash-status-error #splash-status { + color: #fca5a5; + } + #splash.splash-status-slow #splash-status { + color: #fde68a; + } + #splash.splash-status-error #splash-progress-bar { + background: #f87171; + animation: none; + } @keyframes splash-tagline-type { to { clip-path: inset(0 0 0 0); } } + @keyframes splash-progress { + 0% { + transform: translateX(-110%); + } + 55% { + transform: translateX(90%); + } + 100% { + transform: translateX(260%); + } + } /* Logo node breathing - cycles through 3 agent nodes */ @keyframes splash-node { @@ -284,6 +428,26 @@ :root.light #splash-tagline { color: rgba(63, 63, 70, 0.66); } + :root.light #splash-status { + color: rgba(63, 63, 70, 0.7); + } + :root.light #splash-elapsed, + :root.light .splash-step-time { + color: rgba(63, 63, 70, 0.48); + } + :root.light #splash-hint, + :root.light .splash-step-label { + color: rgba(63, 63, 70, 0.58); + } + :root.light .splash-step.is-current .splash-step-label { + color: rgba(39, 39, 42, 0.82); + } + :root.light .splash-step-dot { + background: rgba(63, 63, 70, 0.34); + } + :root.light #splash-progress { + background: rgba(79, 70, 229, 0.14); + } :root.light #splash-noise { opacity: 0.02; } @@ -307,6 +471,7 @@ #splash, #splash-enhanced-canvas, #splash-logo, + #splash-progress-bar, #splash-tagline > span, .splash-node { animation: none !important; @@ -446,6 +611,13 @@
Agent Teams AI
Get more done by doing less.
+
+
Preparing workspace...
+
0s
+
+
+ +
diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 74e4b2c4..2dd7f54e 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -9,6 +9,8 @@ import { App } from './App'; import { initSentryRenderer } from './sentry'; import { initializeNotificationListeners } from './store'; +import type { AppStartupStatus, AppStartupStep } from '@shared/types/api'; + declare global { interface Window { __claudeTeamsUiDidInit?: boolean; @@ -18,16 +20,199 @@ declare global { // Sentry must be initialised before React renders. initSentryRenderer(); -// React 18 StrictMode intentionally mounts/unmounts effects twice in dev, -// which can start duplicate IPC init chains. Make initialization a one-time -// module-level side effect guarded by a global flag. -if (!window.__claudeTeamsUiDidInit) { - window.__claudeTeamsUiDidInit = true; - initializeNotificationListeners(); +let root: ReactDOM.Root | null = null; +let latestStartupStatus: AppStartupStatus | null = null; +let startupTicker: number | undefined; + +const SLOW_STEP_MS = 7_000; +const VERY_SLOW_STEP_MS = 14_000; +const TIMELINE_STEP_LIMIT = 6; + +function getStartupErrorText(status: AppStartupStatus): string { + return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.'; } -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -); +function formatDuration(ms: number): string { + const safeMs = Math.max(0, ms); + const seconds = Math.floor(safeMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return `${minutes}m ${rest.toString().padStart(2, '0')}s`; +} + +function getCurrentStartupStep(status: AppStartupStatus): AppStartupStep | null { + const steps = status.steps ?? []; + const active = [...steps].reverse().find((step) => !step.finishedAt); + return active ?? steps[steps.length - 1] ?? null; +} + +function getStepElapsedMs(step: AppStartupStep | null, status: AppStartupStatus): number { + if (!step) { + return Date.now() - status.startedAt; + } + return step.finishedAt ? step.finishedAt - step.startedAt : Date.now() - step.startedAt; +} + +function getSlowStepHint(step: AppStartupStep | null, elapsedMs: number): string { + if (!step || step.finishedAt || elapsedMs < SLOW_STEP_MS) { + return ''; + } + + const phase = step.phase; + if (phase.includes('shell-env-login') || phase.includes('shell-env-interactive')) { + return elapsedMs >= VERY_SLOW_STEP_MS + ? 'Shell startup is still running. Slow shell profile scripts can delay first launch.' + : 'Reading your shell PATH. This can take a few seconds on first launch.'; + } + if (phase.includes('node-runtime')) { + return 'Checking Node.js for the local MCP server. This can wait up to 5 seconds.'; + } + if (phase.includes('packaged-server-copy')) { + return 'Preparing the packaged MCP server copy. This should only happen after updates.'; + } + if (phase.includes('path') || phase.includes('standard-locations') || phase.includes('nvm')) { + return 'Searching local runtime paths. A large PATH or slow disk can make this step longer.'; + } + if (phase.includes('doctor')) { + return 'Using diagnostics fallback to locate the runtime.'; + } + if (phase.includes('settings')) { + return 'Loading encrypted local settings.'; + } + + return 'Still working on this startup step.'; +} + +function renderStartupTimeline(status: AppStartupStatus): void { + const timeline = document.getElementById('splash-timeline'); + if (!timeline) return; + + const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT); + timeline.replaceChildren(); + + for (const step of steps) { + const row = document.createElement('div'); + const isCurrent = !step.finishedAt && !status.ready && !status.error; + row.className = `splash-step${isCurrent ? ' is-current' : ''}`; + + const dot = document.createElement('div'); + dot.className = 'splash-step-dot'; + + const label = document.createElement('div'); + label.className = 'splash-step-label'; + label.textContent = step.message; + label.title = step.message; + + const time = document.createElement('div'); + time.className = 'splash-step-time'; + time.textContent = formatDuration(getStepElapsedMs(step, status)); + + row.append(dot, label, time); + timeline.append(row); + } +} + +function updateStartupSplash(status: AppStartupStatus): void { + const splash = document.getElementById('splash'); + const statusElement = document.getElementById('splash-status'); + const elapsedElement = document.getElementById('splash-elapsed'); + const hintElement = document.getElementById('splash-hint'); + if (!splash || !statusElement) return; + + latestStartupStatus = status; + const currentStep = getCurrentStartupStep(status); + const elapsedMs = getStepElapsedMs(currentStep, status); + const hint = getSlowStepHint(currentStep, elapsedMs); + + splash.classList.toggle('splash-status-error', Boolean(status.error) && !status.ready); + splash.classList.toggle('splash-status-slow', Boolean(hint) && !status.error && !status.ready); + statusElement.textContent = + status.error && !status.ready + ? getStartupErrorText(status) + : (currentStep?.message ?? status.message); + if (elapsedElement) { + elapsedElement.textContent = formatDuration(elapsedMs); + } + if (hintElement) { + hintElement.textContent = status.error || status.ready ? '' : hint; + } + renderStartupTimeline(status); +} + +function startStartupTicker(): void { + if (startupTicker !== undefined) return; + startupTicker = window.setInterval(() => { + if (latestStartupStatus) { + updateStartupSplash(latestStartupStatus); + } + }, 1000); +} + +function stopStartupTicker(): void { + if (startupTicker === undefined) return; + window.clearInterval(startupTicker); + startupTicker = undefined; +} + +function mountApp(): void { + if (root) return; + + // React 18 StrictMode intentionally mounts/unmounts effects twice in dev, + // which can start duplicate IPC init chains. Make initialization a one-time + // module-level side effect guarded by a global flag. + if (!window.__claudeTeamsUiDidInit) { + window.__claudeTeamsUiDidInit = true; + initializeNotificationListeners(); + } + + root = ReactDOM.createRoot(document.getElementById('root')!); + root.render( + + + + ); +} + +async function bootstrapRenderer(): Promise { + const startupApi = window.electronAPI?.startup; + if (!startupApi) { + mountApp(); + return; + } + + let cleanup = (): void => undefined; + try { + let finished = false; + const handleStartupStatus = (nextStatus: AppStartupStatus): void => { + if (finished) { + return; + } + updateStartupSplash(nextStatus); + if (nextStatus.ready) { + finished = true; + cleanup(); + stopStartupTicker(); + mountApp(); + } else if (nextStatus.error) { + finished = true; + cleanup(); + stopStartupTicker(); + } else { + startStartupTicker(); + } + }; + + cleanup = startupApi.onProgress(handleStartupStatus); + handleStartupStatus(await startupApi.getStatus()); + } catch (error) { + console.warn(`[startup] status bridge unavailable: ${String(error)}`); + cleanup(); + stopStartupTicker(); + mountApp(); + } +} + +void bootstrapRenderer(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d5a19810..eae96093 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -2239,7 +2239,57 @@ function normalizeTeamGraphSlotAssignmentsForVisibleOwners( } normalizedAssignments[stableOwnerId] = assignment; } - return normalizedAssignments; + return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds); +} + +function normalizeLegacySixRowOrbitAssignments( + assignments: TeamGraphSlotAssignments, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments { + if (visibleOwnerIds.length !== 6) { + return assignments; + } + + const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => { + const assignment = assignments[stableOwnerId]; + return assignment ? [assignment] : []; + }); + const hasLegacyTwoRowBottomMarker = visibleAssignments.some( + (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 + ); + let changed = false; + const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments }; + + for (const stableOwnerId of visibleOwnerIds) { + const assignment = normalizedAssignments[stableOwnerId]; + if (!assignment) { + continue; + } + + if ( + hasLegacyTwoRowBottomMarker && + assignment.ringIndex === 1 && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < 3 + ) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex, + }; + changed = true; + continue; + } + + if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex - 3, + }; + changed = true; + } + } + + return changed ? normalizedAssignments : assignments; } function pruneTeamGraphSlotAssignmentsForVisibleOwners( diff --git a/src/renderer/utils/memberActivityTimer.ts b/src/renderer/utils/memberActivityTimer.ts index d52f5c43..3d766ca9 100644 --- a/src/renderer/utils/memberActivityTimer.ts +++ b/src/renderer/utils/memberActivityTimer.ts @@ -332,6 +332,47 @@ export function deriveReviewActivityTimerAnchor( const memberKey = normalizeMemberName(params.memberName); if (!memberKey) return null; + const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []; + for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) { + const interval = reviewIntervals[index]; + if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) { + continue; + } + const startedAtMs = parseIsoMs(interval.startedAt); + if (startedAtMs <= 0) return null; + + const cycleStartedAtMs = getCurrentReviewCycleStartedAtMs(task, startedAtMs); + let baseElapsedMs = 0; + for (let previousIndex = 0; previousIndex < index; previousIndex += 1) { + const previous = reviewIntervals[previousIndex]; + if (normalizeMemberName(previous?.reviewer) !== memberKey) continue; + const previousStartedAtMs = parseIsoMs(previous?.startedAt); + const previousCompletedAtMs = parseIsoMs(previous?.completedAt); + if ( + previousStartedAtMs >= cycleStartedAtMs && + previousStartedAtMs > 0 && + previousCompletedAtMs > previousStartedAtMs + ) { + baseElapsedMs += previousCompletedAtMs - previousStartedAtMs; + } + } + + return { + startedAt: interval.startedAt, + startedAtMs, + baseElapsedMs, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'review', + taskId: task.id, + startedAt: interval.startedAt, + }), + }; + } + + if (reviewIntervals.length > 0) return null; + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; @@ -369,6 +410,27 @@ export function deriveReviewActivityTimerAnchor( return null; } +function getCurrentReviewCycleStartedAtMs(task: TeamTaskWithKanban, fallbackMs: number): number { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'review_started') { + const startedAtMs = parseIsoMs(event.timestamp); + return startedAtMs > 0 ? startedAtMs : fallbackMs; + } + if ( + event.type === 'review_approved' || + event.type === 'review_changes_requested' || + event.type === 'task_created' || + (event.type === 'status_changed' && + (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) + ) { + return fallbackMs; + } + } + return fallbackMs; +} + export function resetMemberActivityTimerStoreForTests(): void { timers.clear(); } diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 60f9c618..d078bf0c 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -133,6 +133,7 @@ export const SPAWN_PRESENCE_LABELS: Record = { }; const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000; +export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000; function isLaunchStillStarting( spawnStatus: MemberSpawnStatus | undefined, @@ -634,6 +635,7 @@ export type MemberLaunchVisualState = | 'queued' | 'waiting' | 'spawning' + | 'starting_stale' | 'permission_pending' | 'bootstrap_stalled' | 'runtime_pending' @@ -666,6 +668,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) return 'waiting to start'; case 'spawning': return 'starting'; + case 'starting_stale': + return 'starting stale'; case 'permission_pending': return 'awaiting permission'; case 'bootstrap_stalled': @@ -700,6 +704,8 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str case 'runtime_pending': case 'runtime_candidate': return 'bg-amber-400 animate-pulse'; + case 'starting_stale': + return 'bg-amber-400'; case 'registered_only': return SPAWN_DOT_COLORS.waiting; case 'shell_only': @@ -794,6 +800,41 @@ function hasElapsedSinceIso( return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs; } +export function isMemberStartingStale({ + spawnStatus, + spawnLaunchState, + spawnFirstSpawnAcceptedAt, + spawnUpdatedAt, + nowMs = Date.now(), +}: { + spawnStatus?: MemberSpawnStatus; + spawnLaunchState?: MemberLaunchState; + spawnFirstSpawnAcceptedAt?: string; + spawnUpdatedAt?: string; + nowMs?: number; +}): boolean { + if ( + spawnLaunchState === 'failed_to_start' || + spawnLaunchState === 'confirmed_alive' || + spawnLaunchState === 'skipped_for_launch' || + spawnLaunchState === 'runtime_pending_permission' || + spawnStatus === 'error' || + spawnStatus === 'online' || + spawnStatus === 'skipped' + ) { + return false; + } + if (spawnLaunchState !== 'starting' && spawnStatus !== 'waiting' && spawnStatus !== 'spawning') { + return false; + } + + return hasElapsedSinceIso( + spawnFirstSpawnAcceptedAt ?? spawnUpdatedAt, + MEMBER_STARTING_STALE_AFTER_MS, + nowMs + ); +} + function hasBootstrapStallDiagnostic(value: string | undefined): boolean { const normalized = value?.trim().toLowerCase() ?? ''; return ( @@ -881,12 +922,15 @@ export function buildMemberLaunchPresentation({ spawnRuntimeAlive, spawnBootstrapConfirmed, spawnBootstrapStalled, + spawnFirstSpawnAcceptedAt, + spawnUpdatedAt, runtimeAdvisory, runtimeEntry, isLaunchSettling = false, isTeamAlive, isTeamProvisioning, leadActivity, + nowMs, }: { member: ResolvedTeamMember; spawnStatus: MemberSpawnStatus | undefined; @@ -895,12 +939,15 @@ export function buildMemberLaunchPresentation({ spawnRuntimeAlive: boolean | undefined; spawnBootstrapConfirmed?: boolean; spawnBootstrapStalled?: boolean; + spawnFirstSpawnAcceptedAt?: string; + spawnUpdatedAt?: string; runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeEntry?: TeamAgentRuntimeEntry; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; + nowMs?: number; }): MemberLaunchPresentation { const hasConfirmedSpawnLaunch = spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true; @@ -943,6 +990,15 @@ export function buildMemberLaunchPresentation({ const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId); const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory); const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; + const startingIsStale = + !hasConfirmedSpawnLaunch && + isMemberStartingStale({ + spawnStatus, + spawnLaunchState, + spawnFirstSpawnAcceptedAt, + spawnUpdatedAt, + nowMs, + }); let launchVisualState: MemberLaunchVisualState = null; if (isTeamAlive !== false || isTeamProvisioning) { @@ -969,6 +1025,8 @@ export function buildMemberLaunchPresentation({ runtimeEntry?.livenessKind === 'not_found') ) { launchVisualState = 'stale_runtime'; + } else if (!hasConfirmedSpawnLaunch && startingIsStale) { + launchVisualState = 'starting_stale'; } else if ( !hasConfirmedSpawnLaunch && isQueuedOpenCodeLaunch( @@ -1007,6 +1065,7 @@ export function buildMemberLaunchPresentation({ const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState); const shouldShowLaunchStatusAsPresence = launchVisualState === 'queued' || + launchVisualState === 'starting_stale' || launchVisualState === 'permission_pending' || launchVisualState === 'bootstrap_stalled' || launchVisualState === 'runtime_pending' || @@ -1023,7 +1082,9 @@ export function buildMemberLaunchPresentation({ const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' - ? 'starting' + ? startingIsStale + ? 'starting stale' + : 'starting' : spawnStatus : null; @@ -1033,7 +1094,7 @@ export function buildMemberLaunchPresentation({ runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : (launchVisualStateDotClass ?? baseDotClass), - cardClass, + cardClass: launchVisualState === 'starting_stale' ? 'opacity-90' : cardClass, runtimeAdvisoryLabel, runtimeAdvisoryTitle, runtimeAdvisoryTone, diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index b09b70b1..b1aaa960 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -13,6 +13,7 @@ export interface MemberLaunchDiagnosticsPayload { teamName?: string; runId?: string; memberName: string; + memberCardError?: string; launchState?: MemberLaunchState; spawnStatus?: MemberSpawnStatus; livenessKind?: TeamAgentRuntimeLivenessKind; @@ -55,6 +56,15 @@ function boundedNumber(value: number | undefined): number | undefined { : undefined; } +export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null { + const normalized = value + ?.replace(/\s+/g, ' ') + .trim() + .replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '') + .replace(/^APIError\s*[-:]\s*/i, ''); + return normalized && normalized.length > 0 ? normalized : null; +} + function uniqueDiagnostics( ...groups: (readonly (string | undefined)[] | undefined)[] ): string[] | undefined { @@ -91,7 +101,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: { boundedString(runtimeEntry?.runtimeDiagnostic) ?? boundedString(spawnEntry?.hardFailureReason) ?? boundedString(spawnEntry?.error); + const memberCardError = boundedString( + normalizeMemberLaunchFailureReason( + spawnEntry?.error ?? + spawnEntry?.hardFailureReason ?? + spawnEntry?.runtimeDiagnostic ?? + runtimeEntry?.runtimeDiagnostic + ) ?? undefined + ); const diagnostics = uniqueDiagnostics( + memberCardError ? [memberCardError] : undefined, runtimeDiagnostic ? [runtimeDiagnostic] : undefined, spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined, spawnEntry?.error ? [spawnEntry.error] : undefined, @@ -103,6 +122,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: { ...(params.teamName ? { teamName: params.teamName } : {}), ...(runId ? { runId } : {}), memberName: params.memberName, + ...(memberCardError ? { memberCardError } : {}), ...((spawnEntry?.launchState ?? params.launchState) ? { launchState: spawnEntry?.launchState ?? params.launchState } : {}), @@ -161,6 +181,7 @@ export function hasMemberLaunchDiagnosticsDetails( return Boolean( (payload.launchState && payload.launchState !== 'confirmed_alive') || (payload.spawnStatus && payload.spawnStatus !== 'online') || + payload.memberCardError || payload.bootstrapStalled === true || weakLiveness || payload.runtimeDiagnostic || @@ -182,7 +203,12 @@ export function getMemberLaunchDiagnosticsErrorMessage( if (!hasMemberLaunchDiagnosticsError(payload)) { return undefined; } - return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed'; + return ( + payload.memberCardError ?? + payload.runtimeDiagnostic ?? + payload.diagnostics?.[0] ?? + 'Launch failed' + ); } export function formatMemberLaunchDiagnosticsPayload( diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index fe4f6d45..2e9c69a7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -319,6 +319,34 @@ export interface UpdaterAPI { onStatus: (callback: (event: unknown, status: unknown) => void) => () => void; } +// ============================================================================= +// Startup API +// ============================================================================= + +export interface AppStartupStatus { + phase: string; + message: string; + ready: boolean; + error?: string | null; + startedAt: number; + updatedAt: number; + steps?: AppStartupStep[]; +} + +export interface AppStartupStep { + phase: string; + message: string; + startedAt: number; + updatedAt: number; + finishedAt?: number; + durationMs?: number; +} + +export interface AppStartupAPI { + getStatus: () => Promise; + onProgress: (callback: (status: AppStartupStatus) => void) => () => void; +} + // ============================================================================= // Context API // ============================================================================= @@ -770,6 +798,7 @@ export interface ReviewAPI { * Complete Electron API exposed to the renderer process via preload script. */ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi { + startup?: AppStartupAPI; getAppVersion: () => Promise; getProjects: () => Promise; getSessions: (projectId: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f8d23599..6a00c75f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -105,6 +105,15 @@ export interface TaskWorkInterval { completedAt?: string; } +export interface TaskReviewInterval { + /** Reviewer actively reviewing during this interval. */ + reviewer: string; + /** ISO timestamp when reviewer started or resumed review. */ + startedAt: string; + /** ISO timestamp when reviewer stopped, paused, approved, or requested changes. */ + completedAt?: string; +} + // --------------------------------------------------------------------------- // Task History Events — unified workflow event log // --------------------------------------------------------------------------- @@ -470,6 +479,10 @@ export interface TeamTask { * We persist intervals for reliable log attribution without relying on heuristics. */ workIntervals?: TaskWorkInterval[]; + /** + * Review work periods, split across runtime pauses/restarts just like workIntervals. + */ + reviewIntervals?: TaskReviewInterval[]; /** * Unified workflow event log. * Append-only — records task creation, status changes, and review transitions. diff --git a/src/shared/utils/anthropicLaunchModel.ts b/src/shared/utils/anthropicLaunchModel.ts index 0659f9fd..630e1144 100644 --- a/src/shared/utils/anthropicLaunchModel.ts +++ b/src/shared/utils/anthropicLaunchModel.ts @@ -10,6 +10,16 @@ function isAnthropicHaikuModel(model: string): boolean { return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-'); } +function isAnthropicSonnetModel(model: string): boolean { + const baseModel = stripOneMillionSuffix(model); + return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-'); +} + +function normalizeStandardOnlyAnthropicModel(model: string): string { + const baseModel = stripOneMillionSuffix(model); + return isAnthropicHaikuModel(baseModel) || isAnthropicSonnetModel(baseModel) ? baseModel : model; +} + function normalizeAvailableLaunchModels( availableLaunchModels: Iterable | undefined ): Set { @@ -52,9 +62,10 @@ export function resolveAnthropicLaunchModel(params: { if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) { const staticDefault = getAnthropicDefaultTeamModel(params.limitContext); const runtimeDefault = params.defaultLaunchModel?.trim() || null; + const rawPreferredDefault = runtimeDefault || staticDefault; const preferredDefault = params.limitContext - ? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault - : runtimeDefault || staticDefault; + ? stripOneMillionSuffix(rawPreferredDefault) || staticDefault + : normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault; if (availableModels.size === 0) { return preferredDefault; } @@ -74,7 +85,11 @@ export function resolveAnthropicLaunchModel(params: { return null; } - if (params.limitContext || isAnthropicHaikuModel(baseModel)) { + if ( + params.limitContext || + isAnthropicHaikuModel(baseModel) || + isAnthropicSonnetModel(baseModel) + ) { return baseModel; } diff --git a/src/shared/utils/teamGraphDefaultLayout.ts b/src/shared/utils/teamGraphDefaultLayout.ts index c100a189..9f206457 100644 --- a/src/shared/utils/teamGraphDefaultLayout.ts +++ b/src/shared/utils/teamGraphDefaultLayout.ts @@ -33,6 +33,90 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignm { ringIndex: 0, sectorIndex: 2 }, { ringIndex: 0, sectorIndex: 3 }, ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 0, sectorIndex: 4 }, + { ringIndex: 0, sectorIndex: 5 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 2 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 2 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 0 }, + { ringIndex: 3, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 0 }, + { ringIndex: 3, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 2 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 1, sectorIndex: 2 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 0 }, + { ringIndex: 3, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 2 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 1, sectorIndex: 0 }, + { ringIndex: 1, sectorIndex: 1 }, + { ringIndex: 1, sectorIndex: 2 }, + { ringIndex: 2, sectorIndex: 0 }, + { ringIndex: 2, sectorIndex: 1 }, + { ringIndex: 2, sectorIndex: 2 }, + { ringIndex: 3, sectorIndex: 0 }, + { ringIndex: 3, sectorIndex: 1 }, + { ringIndex: 3, sectorIndex: 2 }, + ], ]; export function buildOrderedVisibleTeamGraphOwnerIds( diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json new file mode 100644 index 00000000..c4be27d9 --- /dev/null +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json @@ -0,0 +1,268 @@ +{ + "generatedAt": "2026-05-08T18:34:37.950Z", + "runsPerModel": 1, + "qualification": { + "minimumAverageScore": 80, + "minimumSuccessfulRuns": 1, + "minimumConsistencyScore": 85, + "requireNoHardFailures": true + }, + "models": [ + { + "model": "opencode/big-pickle", + "verdict": "tested-only", + "confidence": "low", + "qualified": false, + "readinessScore": 73, + "averageScore": 90, + "consistencyScore": 100, + "behavioralAverageScore": 90, + "minScore": 90, + "successfulRuns": 0, + "countedRuns": 1, + "hardFailures": 1, + "providerInfraFailures": 0, + "runtimeTransportFailures": 0, + "modelBehaviorFailures": 1, + "harnessFailures": 0, + "p50DurationMs": 124249, + "p95DurationMs": 124249, + "stagePassRates": { + "launchBootstrap": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "directReply": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "peerRelayAB": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "peerRelayBC": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "concurrentReplies": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "taskRefs": { + "passed": 0, + "total": 1, + "rate": 0 + }, + "cleanTranscript": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "noDuplicateTokens": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "latencyStable": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + "taskRefPassRates": { + "directReply": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "peerRelayAB": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "peerRelayBC": { + "passed": 1, + "total": 1, + "rate": 100 + }, + "concurrentBob": { + "passed": 0, + "total": 1, + "rate": 0 + }, + "concurrentTom": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + "protocolViolationTotals": { + "badMessages": 0, + "duplicateOrMissingTokens": 0, + "affectedRuns": 0 + }, + "stageFailureImpact": [ + { + "stage": "taskRefs", + "failedRuns": 1, + "weightedLoss": 10, + "passRate": { + "passed": 0, + "total": 1, + "rate": 0 + } + }, + { + "stage": "cleanTranscript", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "concurrentReplies", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "directReply", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "latencyStable", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "launchBootstrap", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "noDuplicateTokens", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "peerRelayAB", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "peerRelayBC", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + } + ], + "scoreStability": { + "sampleSize": 1, + "minScore": 90, + "maxScore": 90, + "spread": 0, + "standardDeviation": 0, + "consistencyScore": 100 + }, + "dominantFailureCategory": "model-behavior", + "recommendationBlockers": [ + "successful runs 0 < 1", + "hard failures 1", + "model-behavior failures 1", + "highest weighted stage loss taskRefs=10", + "weakest taskRefs concurrentBob=0/1 (0%)" + ], + "runs": [ + { + "runIndex": 1, + "passed": false, + "score": 90, + "countedForRecommendation": true, + "outcome": "behavioral-fail", + "failureCategory": "model-behavior", + "primaryFailure": null, + "durationMs": 124249, + "hardFailure": true, + "stageDurationsMs": { + "setup": 214, + "launchBootstrap": 23875, + "materializeTasks": 32, + "directReply": 11617, + "peerRelayAB": 27950, + "peerRelayBC": 25689, + "concurrentReplies": 25243, + "hygiene": 1 + }, + "stageFailures": {}, + "taskRefChecks": { + "directReply": true, + "peerRelayAB": true, + "peerRelayBC": true, + "concurrentBob": false, + "concurrentTom": true + }, + "protocolViolations": { + "badMessages": 0, + "duplicateOrMissingTokens": [] + }, + "stages": { + "launchBootstrap": true, + "directReply": true, + "peerRelayAB": true, + "peerRelayBC": true, + "concurrentReplies": true, + "taskRefs": false, + "cleanTranscript": true, + "noDuplicateTokens": true, + "latencyStable": true + }, + "diagnostics": [ + "runId=34e07fb0-df87-4419-be0c-0f5386847b23" + ] + } + ] + } + ] +} diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md new file mode 100644 index 00000000..614e4d17 --- /dev/null +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md @@ -0,0 +1,37 @@ +# OpenCode Model Gauntlet Results + +Generated: 2026-05-08T18:34:37.950Z + +Runs per model: 1 +Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0 + +Provider-infra runs are reported separately and are not counted as model behavior. They still block a Recommended verdict until rerun succeeds. + +Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC=15, concurrentReplies=15, taskRefs=10, cleanTranscript=10, noDuplicateTokens=5, latencyStable=5. + +## Model Summary + +| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms | + +## opencode/big-pickle + +Readiness score: 73. + +Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1. + +Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%). + +Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%). + +Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%). + +TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%). + +Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0. + +| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics | +| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- | +| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 | + diff --git a/test/agent-graph/stableSlots.test.ts b/test/agent-graph/stableSlots.test.ts new file mode 100644 index 00000000..263e4397 --- /dev/null +++ b/test/agent-graph/stableSlots.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildStableSlotLayoutSnapshot, + resolveNearestSlotAssignment, + type StableRect, + type StableSlotLayoutSnapshot, + validateStableSlotLayout, +} from '../../packages/agent-graph/src/layout/stableSlots'; + +import type { + GraphLayoutPort, + GraphNode, + GraphOwnerSlotAssignment, +} from '../../packages/agent-graph/src/ports/types'; + +function ownerNode(id: string, kind: 'lead' | 'member' = 'member'): GraphNode { + return { + id, + kind, + label: id, + state: 'idle', + domainRef: { + kind, + teamName: 'test-team', + memberName: id, + }, + }; +} + +function taskNode(id: string, ownerId: string, index: number): GraphNode { + return { + id, + kind: 'task', + label: id, + state: 'idle', + ownerId, + taskStatus: index === 0 ? 'in_progress' : 'pending', + reviewState: index === 1 ? 'review' : 'none', + domainRef: { + kind: 'task', + teamName: 'test-team', + taskId: id, + }, + }; +} + +function buildOwnerGraph( + ownerCount: number, + slotAssignments: Record +): { nodes: GraphNode[]; layout: GraphLayoutPort } { + const nodes: GraphNode[] = [ownerNode('lead', 'lead')]; + const ownerOrder = Array.from({ length: ownerCount }, (_, index) => `member-${index}`); + + ownerOrder.forEach((ownerId, ownerIndex) => { + nodes.push(ownerNode(ownerId)); + + for (let taskIndex = 0; taskIndex < 3; taskIndex += 1) { + nodes.push(taskNode(`task-${ownerIndex}-${taskIndex}`, ownerId, taskIndex)); + } + }); + + return { + nodes, + layout: { + version: 'stable-slots-v1', + mode: 'radial', + ownerOrder, + slotAssignments, + }, + }; +} + +function buildSixOwnerGraph(): { nodes: GraphNode[]; layout: GraphLayoutPort } { + return buildOwnerGraph( + 6, + Object.fromEntries( + Array.from({ length: 6 }, (_, index) => [ + `member-${index}`, + { ringIndex: 0, sectorIndex: index }, + ]) + ) + ); +} + +function buildRowOrbitGraph( + ownerCount: number, + rowCounts: readonly number[] +): { + nodes: GraphNode[]; + layout: GraphLayoutPort; +} { + const assignments: Record = {}; + let ownerIndex = 0; + rowCounts.forEach((columnCount, ringIndex) => { + for (let sectorIndex = 0; sectorIndex < columnCount; sectorIndex += 1) { + assignments[`member-${ownerIndex}`] = { ringIndex, sectorIndex }; + ownerIndex += 1; + } + }); + return buildOwnerGraph(ownerCount, assignments); +} + +function getSnapshot(nodes: GraphNode[], layout: GraphLayoutPort): StableSlotLayoutSnapshot { + const snapshot = buildStableSlotLayoutSnapshot({ + teamName: 'test-team', + nodes, + layout, + }); + expect(snapshot).not.toBeNull(); + expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); + return snapshot!; +} + +function rectsOverlap(left: StableRect, right: StableRect): boolean { + return ( + left.left < right.right && + left.right > right.left && + left.top < right.bottom && + left.bottom > right.top + ); +} + +function getRowCounts(snapshot: StableSlotLayoutSnapshot): number[] { + const rowCounts = new Map(); + for (const frame of snapshot.memberSlotFrames) { + rowCounts.set(frame.ringIndex, (rowCounts.get(frame.ringIndex) ?? 0) + 1); + } + return Array.from(rowCounts.entries()) + .sort(([left], [right]) => left - right) + .map(([, count]) => count); +} + +function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] { + const rows = new Map(); + for (const frame of snapshot.memberSlotFrames) { + rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame.bounds]); + } + return Array.from(rows.entries()) + .sort(([left], [right]) => left - right) + .map(([, rects]) => { + const left = Math.min(...rects.map((rect) => rect.left)); + const right = Math.max(...rects.map((rect) => rect.right)); + return right - left; + }); +} + +describe('stable slot layout', () => { + it('packs six legacy radial owners into two row-orbit rows', () => { + const { nodes, layout } = buildSixOwnerGraph(); + const snapshot = getSnapshot(nodes, layout); + + expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit'); + expect(getRowCounts(snapshot)).toEqual([3, 3]); + expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 2, 2, 2]); + expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]); + }); + + it('lets six radial owners move into an empty lead-level side slot', () => { + const { nodes, layout } = buildSixOwnerGraph(); + const snapshot = getSnapshot(nodes, layout); + const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!; + const targetOwnerX = + snapshot.runtimeCentralExclusion.left - 160 - currentFrame.bounds.width / 2; + + const result = resolveNearestSlotAssignment({ + ownerId: 'member-0', + ownerX: targetOwnerX, + ownerY: 0, + nodes, + snapshot, + layout, + }); + + expect(result).toMatchObject({ + assignment: { ringIndex: 1, sectorIndex: 0 }, + previewOwnerX: targetOwnerX, + previewOwnerY: 0, + }); + expect(result?.displacedOwnerId).toBeUndefined(); + + const nextSnapshot = getSnapshot(nodes, { + ...layout, + slotAssignments: { + ...layout.slotAssignments, + 'member-0': result!.assignment, + }, + }); + expect(nextSnapshot.ownerSlotLayoutKind).toBe('row-orbit'); + expect(nextSnapshot.memberSlotFrameByOwnerId.get('member-0')).toMatchObject({ + ringIndex: 1, + sectorIndex: 0, + }); + }); + + it('uses three grid columns for six owners in rows layout', () => { + const { nodes, layout } = buildSixOwnerGraph(); + const snapshot = getSnapshot(nodes, { + ...layout, + mode: 'grid-under-lead', + slotAssignments: {}, + }); + + expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead'); + expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]); + expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]); + }); + + it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => { + const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]); + const snapshot = getSnapshot(nodes, layout); + + expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit'); + expect(getRowCounts(snapshot)).toEqual([3, 2, 3]); + + const leadRowFrames = snapshot.memberSlotFrames.filter((frame) => frame.ringIndex === 1); + expect(leadRowFrames).toHaveLength(2); + for (const frame of leadRowFrames) { + expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false); + if (frame.ownerX < 0) { + expect(frame.bounds.right).toBeLessThanOrEqual(snapshot.runtimeCentralExclusion.left - 160); + } else { + expect(frame.bounds.left).toBeGreaterThanOrEqual( + snapshot.runtimeCentralExclusion.right + 160 + ); + } + } + }); + + it('packs twelve radial owners into four safe rows with no four-column row width', () => { + const { nodes, layout } = buildRowOrbitGraph(12, [3, 3, 3, 3]); + const snapshot = getSnapshot(nodes, layout); + + expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit'); + expect(getRowCounts(snapshot)).toEqual([3, 3, 3, 3]); + + const maxFrameWidth = Math.max(...snapshot.memberSlotFrames.map((frame) => frame.bounds.width)); + const maxRowWidth = Math.max(...getRowWidths(snapshot)); + expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4); + }); + + it('swaps with the nearest existing row-orbit slot while dragging', () => { + const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]); + const snapshot = getSnapshot(nodes, layout); + const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!; + const targetFrame = snapshot.memberSlotFrameByOwnerId.get('member-4')!; + + const result = resolveNearestSlotAssignment({ + ownerId: 'member-0', + ownerX: targetFrame.ownerX, + ownerY: targetFrame.ownerY, + nodes, + snapshot, + layout, + }); + + expect(result).toEqual({ + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + displacedOwnerId: 'member-4', + displacedAssignment: { + ringIndex: currentFrame.ringIndex, + sectorIndex: currentFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }); + }); +}); diff --git a/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts b/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts index 929aa234..c1ed7b79 100644 --- a/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts +++ b/test/main/services/team/ProcessBootstrapTransportEvidence.test.ts @@ -108,10 +108,28 @@ describe('ProcessBootstrapTransportEvidence', () => { expect(summary).not.toBeNull(); expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe( - 'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.' + 'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap prompt observed: prompt seen.' ); expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe( - 'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen' + 'Bootstrap prompt was not submitted before timeout. Last transport stage: bootstrap prompt observed: prompt seen' + ); + }); + + it('distinguishes submitted bootstrap prompts from not-submitted transport timeouts', () => { + const summary = summarizeProcessBootstrapTransportEvents([ + { + type: 'bootstrap_submitted', + timestamp: '2026-05-07T10:00:02.000Z', + detail: 'messageId=abc', + }, + ]); + + expect(summary).not.toBeNull(); + expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe( + 'Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: bootstrap submitted: messageId=abc.' + ); + expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe( + 'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: bootstrap submitted: messageId=abc' ); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e3adcecf..a0cd9245 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -13734,7 +13734,7 @@ describe('TeamProvisioningService', () => { runtimeDiagnosticSeverity: 'warning', }); expect(result.statuses.jack?.runtimeDiagnostic).toContain( - 'Bootstrap transport reached bootstrap submit rejected' + 'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap submit rejected' ); }); diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts new file mode 100644 index 00000000..33c1ee7d --- /dev/null +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -0,0 +1,250 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +import { TeamTaskActivityIntervalService } from '../../../../src/main/services/team/TeamTaskActivityIntervalService'; + +let tempDir = ''; + +async function writeTask(teamName: string, task: Record): Promise { + const taskDir = path.join(tempDir, 'tasks', teamName); + const taskId = String(task.id); + await fs.mkdir(taskDir, { recursive: true }); + await fs.writeFile(path.join(taskDir, `${taskId}.json`), JSON.stringify(task, null, 2), 'utf8'); +} + +async function readTask(teamName: string, taskId: string): Promise> { + return JSON.parse( + await fs.readFile(path.join(tempDir, 'tasks', teamName, `${taskId}.json`), 'utf8') + ) as Record; +} + +describe('TeamTaskActivityIntervalService', () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-task-activity-')); + setClaudeBasePathOverride(tempDir); + }); + + afterEach(async () => { + vi.useRealTimers(); + setClaudeBasePathOverride(null); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('pauses all active work and review intervals for a team without changing task status', async () => { + await writeTask('alpha', { + id: 'task-1', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:05:00.000Z' }], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:10:00.000Z' + ); + const task = await readTask('alpha', 'task-1'); + + expect(result.changedTasks).toBe(1); + expect(task.status).toBe('in_progress'); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' }, + ]); + expect(task.reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:05:00.000Z', + completedAt: '2026-05-08T10:10:00.000Z', + }, + ]); + }); + + it('pauses only the selected member work and review intervals', async () => { + await writeTask('alpha', { + id: 'bob-task', + subject: 'Bob', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' }], + historyEvents: [], + }); + await writeTask('alpha', { + id: 'tom-task', + subject: 'Tom', + owner: 'tom', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + reviewIntervals: [{ reviewer: 'bob', startedAt: '2026-05-08T10:02:00.000Z' }], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:05:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'bob-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ]); + expect((await readTask('alpha', 'bob-task')).reviewIntervals).toEqual([ + { reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' }, + ]); + expect((await readTask('alpha', 'tom-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z' }, + ]); + expect((await readTask('alpha', 'tom-task')).reviewIntervals).toEqual([ + { + reviewer: 'bob', + startedAt: '2026-05-08T10:02:00.000Z', + completedAt: '2026-05-08T10:05:00.000Z', + }, + ]); + }); + + it('resumes active work and current review intervals for the selected member', async () => { + await writeTask('alpha', { + id: 'task-1', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + reviewIntervals: [ + { + reviewer: 'bob', + startedAt: '2026-05-08T10:06:00.000Z', + completedAt: '2026-05-08T10:08:00.000Z', + }, + ], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:06:00.000Z', + actor: 'bob', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:20:00.000Z' + ); + const task = await readTask('alpha', 'task-1'); + + expect(result.changedTasks).toBe(1); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + { startedAt: '2026-05-08T10:20:00.000Z' }, + ]); + expect(task.reviewIntervals).toEqual([ + { + reviewer: 'bob', + startedAt: '2026-05-08T10:06:00.000Z', + completedAt: '2026-05-08T10:08:00.000Z', + }, + { reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' }, + ]); + }); + + it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z')); + await writeTask('alpha', { + id: 'task-1', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', { + version: 2, + teamName: 'alpha', + updatedAt: '2026-05-08T10:31:00.000Z', + launchPhase: 'active', + expectedMembers: ['bob', 'alice'], + members: { + bob: { + name: 'bob', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeLastSeenAt: '2026-05-08T10:30:00.000Z', + lastEvaluatedAt: '2026-05-08T10:31:00.000Z', + }, + alice: { + name: 'alice', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastHeartbeatAt: '2026-05-08T10:20:00.000Z', + lastEvaluatedAt: '2026-05-08T10:31:00.000Z', + }, + }, + summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 }, + teamLaunchState: 'clean_success', + }); + const task = await readTask('alpha', 'task-1'); + + expect(result.changedTasks).toBe(1); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' }, + ]); + expect(task.reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:10:00.000Z', + completedAt: '2026-05-08T10:20:05.000Z', + }, + ]); + }); + + it('repairs stale open intervals near their start time when no runtime evidence exists', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z')); + await writeTask('alpha', { + id: 'task-1', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }], + reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }], + historyEvents: [], + }); + + const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha'); + const task = await readTask('alpha', 'task-1'); + + expect(result.changedTasks).toBe(1); + expect(task.workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:00:05.000Z' }, + ]); + expect(task.reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:10:00.000Z', + completedAt: '2026-05-08T10:10:05.000Z', + }, + ]); + }); +}); diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 57192f64..d3096ff2 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -21,7 +21,7 @@ import { } from '../../../src/main/utils/electronUserDataMigration'; class FakeElectronApp implements ElectronUserDataMigrationApp { - setPathCalls: Array<{ name: string; value: string }> = []; + setPathCalls: { name: string; value: string }[] = []; constructor(private userDataPath: string) {} @@ -74,9 +74,9 @@ describe('electron userData migration', () => { const parentPath = path.dirname(currentPath); expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([ + path.join(parentPath, 'agent-teams-ai'), path.join(parentPath, 'Claude Agent Teams UI'), path.join(parentPath, 'claude-agent-teams-ui'), - path.join(parentPath, 'agent-teams-ai'), path.join(parentPath, 'claude-devtools'), path.join(parentPath, 'claude-code-context'), ]); @@ -106,6 +106,34 @@ describe('electron userData migration', () => { expect(fs.existsSync(currentPath)).toBe(false); }); + it('does not invoke the copy migration in the default startup strategy', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app, { + copyDirectory: () => { + throw new Error('copy should not run during default startup'); + }, + }); + + expect(result).toMatchObject({ + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + expect(fs.existsSync(currentPath)).toBe(false); + }); + it('does not treat a cache-only new userData directory as populated', () => { const root = createTempRoot(); const legacyPath = path.join(root, 'claude-agent-teams-ui'); @@ -132,6 +160,137 @@ describe('electron userData migration', () => { ]); }); + it('does not treat Electron-generated shell files as populated new userData', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(currentPath, 'Preferences', '{}'); + writeFile(currentPath, 'Cookies', 'sqlite bytes'); + writeFile(currentPath, 'DIPS', 'tracking state'); + writeFile(currentPath, 'WebStorage/QuotaManager', 'quota'); + writeFile(currentPath, '.updaterId', 'updater'); + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + }); + + it('does not treat regenerated runtime-only folders as completed migration evidence', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}'); + writeFile(currentPath, 'mcp-server/1.3.0/index.js', 'console.log("generated")'); + writeFile(currentPath, 'mcp-configs/agent-teams-mcp-generated.json', '{}'); + writeFile(currentPath, 'Local Storage/leveldb/000003.log', 'renderer local storage'); + writeFile(currentPath, 'IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'idb'); + writeFile(currentPath, 'Partitions/dev/Local Storage/leveldb/000003.log', 'partition state'); + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: legacyPath }, + { name: 'sessionData', value: legacyPath }, + ]); + }); + + it('keeps a populated new userData directory after a completed migration', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'agent-teams-ai'); + const app = new FakeElectronApp(currentPath); + + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current'); + writeFile(currentPath, 'backups/registry.json', '{}'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'current-populated', + }); + expect(app.setPathCalls).toEqual([]); + }); + + it('prefers an already populated agent-teams-ai directory over older legacy data', () => { + const root = createTempRoot(); + const completedNewPath = path.join(root, 'agent-teams-ai'); + const olderLegacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'Agent Teams UI'); + const app = new FakeElectronApp(currentPath); + + writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}'); + writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current'); + writeFile(olderLegacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath: completedNewPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: completedNewPath }, + { name: 'sessionData', value: completedNewPath }, + ]); + }); + + it('uses populated agent-teams-ai when both current product-name and new package-name paths exist', () => { + const root = createTempRoot(); + const completedNewPath = path.join(root, 'agent-teams-ai'); + const currentProductPath = path.join(root, 'Agent Teams UI'); + const app = new FakeElectronApp(currentProductPath); + + writeFile(currentProductPath, 'data/attachments/team-a/old.txt', 'old'); + writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current'); + + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath: currentProductPath, + legacyPath: completedNewPath, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-reused', + }); + expect(app.setPathCalls).toEqual([ + { name: 'userData', value: completedNewPath }, + { name: 'sessionData', value: completedNewPath }, + ]); + expect(readFile(completedNewPath, 'data/attachments/team-a/current.txt')).toBe('current'); + expect(readFile(currentProductPath, 'data/attachments/team-a/old.txt')).toBe('old'); + }); + it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => { const root = createTempRoot(); const legacyPath = path.join(root, 'Claude Agent Teams UI'); @@ -383,12 +542,12 @@ describe('electron userData migration', () => { }); }); - it('uses the lowercase package-name legacy directory when product-name legacy data is absent', () => { + it('uses the lowercase package-name legacy directory when product-name durable data is absent', () => { const root = createTempRoot(); const legacyPath = path.join(root, 'claude-agent-teams-ui'); const currentPath = path.join(root, 'Agent Teams UI'); - writeFile(legacyPath, 'mcp-configs/legacy.json', '{}'); + writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); const app = new FakeElectronApp(currentPath); const result = migrateElectronUserDataDirectory(app); @@ -404,7 +563,31 @@ describe('electron userData migration', () => { { name: 'userData', value: legacyPath }, { name: 'sessionData', value: legacyPath }, ]); - expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false); + expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/legacy.txt'))).toBe( + false + ); + }); + + it('does not reuse non-durable legacy directories when no durable user data exists', () => { + const root = createTempRoot(); + const legacyPath = path.join(root, 'claude-agent-teams-ui'); + const currentPath = path.join(root, 'Agent Teams UI'); + + writeFile(legacyPath, 'mcp-configs/legacy.json', '{}'); + writeFile(legacyPath, 'opencode-bridge/command-ledger.json', '{"commands":[]}'); + writeFile(legacyPath, 'Local Storage/leveldb/000003.log', 'renderer local storage'); + + const app = new FakeElectronApp(currentPath); + const result = migrateElectronUserDataDirectory(app); + + expect(result).toMatchObject({ + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-missing', + }); + expect(app.setPathCalls).toEqual([]); }); it('prefers populated older legacy data over an empty newer legacy directory', () => { diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index e944ee85..f8a02a4b 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -128,9 +128,12 @@ describe('formatTeamModelSummary', () => { }); describe('computeEffectiveTeamModel', () => { - it('appends [1m] for anthropic models', () => { + it('appends [1m] for Opus but keeps Sonnet on standard context', () => { expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]'); - expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]'); + expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet'); + expect(computeEffectiveTeamModel('claude-sonnet-4-6', false, 'anthropic')).toBe( + 'claude-sonnet-4-6' + ); }); it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => { @@ -177,7 +180,7 @@ describe('computeEffectiveTeamModel', () => { it('does not double-append [1m] when input already has it', () => { expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]'); - expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]'); + expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet'); expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]'); }); @@ -185,6 +188,56 @@ describe('computeEffectiveTeamModel', () => { expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]'); }); + it('keeps a Sonnet runtime default on standard context', () => { + expect( + computeEffectiveTeamModel('', false, 'anthropic', { + providerId: 'anthropic', + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'sonnet[1m]', + defaultLaunchModel: 'sonnet[1m]', + models: [ + { + id: 'sonnet', + launchModel: 'sonnet', + displayName: 'Sonnet 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'sonnet[1m]', + launchModel: 'sonnet[1m]', + displayName: 'Sonnet 4.6 (1M)', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }) + ).toBe('sonnet'); + }); + it('returns base model without [1m] when limitContext is true', () => { expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 9a6095aa..f927ba79 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -975,10 +975,10 @@ describe('TeamModelSelector disabled Codex models', () => { onValueChange: () => undefined, providerDisabledReasonById: { opencode: - 'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.', + 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.', }, providerDisabledBadgeLabelById: { - opencode: 'side lane', + opencode: 'team only', }, }) ); @@ -990,9 +990,9 @@ describe('TeamModelSelector disabled Codex models', () => { ); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toBe( - 'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.' + 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.' ); - expect(openCodeButton?.textContent).toContain('side lane'); + expect(openCodeButton?.textContent).toContain('team only'); await act(async () => { openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 10b545bf..258f9d4f 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -135,7 +135,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ name: draft.name, role: draft.customRole || undefined, workflow: draft.workflow, - providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined, + providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined, providerBackendId: draft.providerBackendId as 'codex-native' | undefined, model: draft.model, effort: draft.effort as 'low' | 'medium' | 'high' | undefined, @@ -170,8 +170,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ fastMode: member.fastMode, })), filterEditableMemberInputs: (members: unknown) => members, - normalizeLeadProviderForMode: (providerId: unknown) => - providerId === 'opencode' ? 'anthropic' : providerId, + normalizeLeadProviderForMode: (providerId: unknown) => providerId, normalizeMemberDraftForProviderMode: (member: unknown) => member, normalizeProviderForMode: (providerId: unknown) => providerId, validateMemberNameInline: () => null, @@ -385,9 +384,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ computeEffectiveTeamModel: (model: string) => model || undefined, formatTeamModelSummary: (providerId: string, model: string, effort?: string) => [providerId, model, effort].filter(Boolean).join(' '), - OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane', - OPENCODE_TEAM_LEAD_DISABLED_REASON: - 'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.', + OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only', + OPENCODE_ONE_SHOT_DISABLED_REASON: + 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.', })); vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ @@ -745,7 +744,7 @@ describe('LaunchTeamDialog', () => { }); }); - it('normalizes saved OpenCode lead hydration away from the unsupported lead path', async () => { + it('launches a saved pure OpenCode team with OpenCode as the lead provider', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.mocked(isTeamModelAvailableForUi).mockImplementation( (_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false @@ -769,7 +768,7 @@ describe('LaunchTeamDialog', () => { }, ], } as any; - vi.mocked(api.teams.getSavedRequest).mockResolvedValue({ + vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({ teamName: 'team-alpha', providerId: 'opencode', model: 'opencode/minimax-m2.5-free', @@ -777,7 +776,8 @@ describe('LaunchTeamDialog', () => { { name: 'alice', role: 'Reviewer', - model: 'gemini-3-pro-preview', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', }, ], } as any); @@ -812,7 +812,7 @@ describe('LaunchTeamDialog', () => { const opencodePrepareCalls = vi .mocked(runProviderPrepareDiagnostics) .mock.calls.filter((call) => call[0]?.providerId === 'opencode'); - expect(opencodePrepareCalls).toHaveLength(0); + expect(opencodePrepareCalls.length).toBeGreaterThan(0); const submitButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent === 'Launch team' @@ -831,7 +831,8 @@ describe('LaunchTeamDialog', () => { { name: 'alice', role: 'Reviewer', - model: '', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', }, ], }); @@ -840,9 +841,217 @@ describe('LaunchTeamDialog', () => { onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]> )[0]?.[0] as { providerId?: string; model?: string } | undefined; expect(launchRequest).toMatchObject({ - providerId: 'anthropic', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', }); - expect(launchRequest?.model).not.toBe('opencode/minimax-m2.5-free'); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('blocks OpenCode lead launch until a model is selected', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['opencode/minimax-m2.5-free'], + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({ + teamName: 'team-alpha', + providerId: 'opencode', + model: '', + members: [{ name: 'alice', role: 'Reviewer', providerId: 'opencode' }], + } as any); + const onLaunch = vi.fn(async () => {}); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch, + }) + ); + await flush(); + await flush(); + }); + + expect(host.textContent).toContain('OpenCode lead requires a selected model.'); + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Launch team' + ); + expect(submitButton?.hasAttribute('disabled')).toBe(true); + expect(onLaunch).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('blocks OpenCode lead launch without an OpenCode teammate', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['opencode/minimax-m2.5-free'], + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({ + teamName: 'team-alpha', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + members: [], + } as any); + const onLaunch = vi.fn(async () => {}); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch, + }) + ); + await flush(); + await flush(); + }); + + expect(host.textContent).toContain('OpenCode lead requires at least one OpenCode teammate.'); + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Launch team' + ); + expect(submitButton?.hasAttribute('disabled')).toBe(true); + expect(onLaunch).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('keeps OpenCode lead mixed-provider launches blocked', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['opencode/minimax-m2.5-free'], + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'codex_api_key', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + models: ['gpt-5.4'], + capabilities: { + teamLaunch: true, + oneShot: false, + }, + }, + ], + } as any; + vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({ + teamName: 'team-alpha', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + members: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }], + } as any); + const onLaunch = vi.fn(async () => {}); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch, + }) + ); + await flush(); + await flush(); + }); + + expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams'); + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Launch team' + ); + expect(submitButton?.hasAttribute('disabled')).toBe(true); + expect(onLaunch).not.toHaveBeenCalled(); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts index db38cd37..bd799141 100644 --- a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts +++ b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts @@ -94,7 +94,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.blocksSubmission).toBe(true); expect(result.title).toBe('OpenCode cannot lead mixed-provider teams'); - expect(result.message).toContain('OpenCode-led mixed teams are not supported'); + expect(result.message).toContain('mixed teams cannot use OpenCode as the lead'); expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead'); }); diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts index 3b8f31d2..f950feaa 100644 --- a/test/renderer/components/team/members/membersEditorUtils.test.ts +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -13,8 +13,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import type { ResolvedTeamMember } from '@shared/types'; describe('members editor editable input filtering', () => { - it('normalizes OpenCode away from the team lead while keeping other multimodel providers', () => { - expect(normalizeLeadProviderForMode('opencode', true)).toBe('anthropic'); + it('keeps OpenCode available for the team lead only when multimodel is enabled', () => { + expect(normalizeLeadProviderForMode('opencode', true)).toBe('opencode'); expect(normalizeLeadProviderForMode('codex', true)).toBe('codex'); expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic'); expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic'); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 4d3c0f6a..ff96d59a 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -228,7 +228,9 @@ function restoreWindowAnimationFrame(): void { originalWindowAnimationFrame.hasRequest ? originalWindowAnimationFrame.requestAnimationFrame : undefined, - originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined + originalWindowAnimationFrame.hasCancel + ? originalWindowAnimationFrame.cancelAnimationFrame + : undefined ); } @@ -524,9 +526,7 @@ describe('teamSlice actions', () => { member: 'bob', text: 'hello', }); - await store - .getState() - .refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending'); + await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending'); expect(store.getState().sendMessageWarning).toBe( 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.' @@ -975,6 +975,51 @@ describe('teamSlice actions', () => { }); }); + it('normalizes legacy six-owner row-orbit slots before preserving manual layout', () => { + const store = createSliceStore(); + const members = [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + { name: 'nova', agentId: 'agent-nova' }, + { name: 'atlas', agentId: 'agent-atlas' }, + ]; + store.setState({ + slotAssignmentsByTeam: { + 'my-team': { + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-atlas': { ringIndex: 0, sectorIndex: 1 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 1, sectorIndex: 0 }, + 'agent-nova': { ringIndex: 1, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 1, sectorIndex: 2 }, + }, + }, + graphLayoutSessionByTeam: { + 'my-team': { + mode: 'manual', + signature: null, + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', members); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-atlas': { ringIndex: 0, sectorIndex: 1 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 2, sectorIndex: 0 }, + 'agent-nova': { ringIndex: 2, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 2, sectorIndex: 2 }, + }); + expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({ + mode: 'manual', + signature: null, + }); + }); + it('resets graph slot assignments back to defaults when reopening the graph surface', () => { const store = createSliceStore(); store.setState({ @@ -1352,10 +1397,7 @@ describe('teamSlice actions', () => { const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); expect(hoisted.getData).toHaveBeenCalledTimes(2); - expect(hoisted.getData.mock.calls[0]).toEqual([ - 'my-team', - { includeMemberBranches: false }, - ]); + expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]); expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']); thinRequest.resolve(thinSnapshot); @@ -1414,7 +1456,9 @@ describe('teamSlice actions', () => { hoisted.getData .mockImplementationOnce(() => alphaThin.promise) - .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })) + .mockResolvedValueOnce( + createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }) + ) .mockResolvedValueOnce(alphaFull); const alphaSelect = store.getState().selectTeam('alpha-team'); @@ -1427,7 +1471,9 @@ describe('teamSlice actions', () => { await store.getState().selectTeam('beta-team'); - alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })); + alphaThin.resolve( + createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } }) + ); await alphaSelect; await flushAsyncWork(); @@ -1509,8 +1555,12 @@ describe('teamSlice actions', () => { const store = createSliceStore(); hoisted.getData - .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })) - .mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })); + .mockResolvedValueOnce( + createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } }) + ) + .mockResolvedValueOnce( + createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }) + ); await store.getState().selectTeam('alpha-team'); expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({ @@ -3480,9 +3530,7 @@ describe('teamSlice actions', () => { const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team'); - expect(result.failed).toEqual([ - { memberName: 'alice', error: 'OpenRouter credits exhausted' }, - ]); + expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]); expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team'); expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); diff --git a/test/renderer/utils/memberActivityTimer.test.ts b/test/renderer/utils/memberActivityTimer.test.ts index b5f824ee..054753c9 100644 --- a/test/renderer/utils/memberActivityTimer.test.ts +++ b/test/renderer/utils/memberActivityTimer.test.ts @@ -165,6 +165,42 @@ describe('memberActivityTimer', () => { ).toBe('2026-05-07T09:35:00.000Z'); }); + it('anchors review timers to persisted review intervals and adds paused review time', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'alice', + historyEvents: [ + { + id: 'evt-1', + type: 'review_started', + from: 'review', + to: 'review', + actor: 'alice', + timestamp: '2026-05-07T09:30:00.000Z', + }, + ], + reviewIntervals: [ + { + reviewer: 'alice', + startedAt: '2026-05-07T09:30:00.000Z', + completedAt: '2026-05-07T09:35:00.000Z', + }, + { reviewer: 'alice', startedAt: '2026-05-07T09:40:00.000Z' }, + ], + }; + + const anchor = deriveReviewActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'alice', + }); + + expect(anchor?.startedAt).toBe('2026-05-07T09:40:00.000Z'); + expect(anchor?.baseElapsedMs).toBe(300_000); + }); + it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => { const timerId = createMemberActivityTimerId({ teamName: 'alpha', diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 7395d715..fbc26bf0 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -195,6 +195,30 @@ describe('memberHelpers spawn-aware presence', () => { }); }); + it('marks long-running starting states as stale without making them failed', () => { + const presentation = buildMemberLaunchPresentation({ + member, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + spawnUpdatedAt: '2026-05-08T12:00:00.000Z', + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + nowMs: Date.parse('2026-05-08T12:03:00.000Z'), + }); + + expect(presentation.presenceLabel).toBe('starting stale'); + expect(presentation.launchVisualState).toBe('starting_stale'); + expect(presentation.launchStatusLabel).toBe('starting stale'); + expect(presentation.dotClass).toContain('bg-amber-400'); + expect(presentation.dotClass).not.toContain('animate-pulse'); + expect(presentation.cardClass).not.toContain('member-waiting-shimmer'); + expect(presentation.spawnBadgeLabel).toBe('starting stale'); + }); + it('keeps OpenCode runtime evidence states more specific than queued', () => { const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts index 9c8e63c0..189d4997 100644 --- a/test/renderer/utils/memberLaunchDiagnostics.test.ts +++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts @@ -4,6 +4,7 @@ import { buildMemberLaunchDiagnosticsPayload, formatMemberLaunchDiagnosticsPayload, hasMemberLaunchDiagnosticsDetails, + getMemberLaunchDiagnosticsErrorMessage, } from '@renderer/utils/memberLaunchDiagnostics'; describe('member launch diagnostics', () => { @@ -62,4 +63,31 @@ describe('member launch diagnostics', () => { expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true); expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"'); }); + + it('includes the exact normalized member card error in copy diagnostics', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'jack', + spawnEntry: { + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: + 'Latest assistant message msg_123 failed with APIError - OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys', + runtimeDiagnostic: 'persisted runtime pid is not alive', + runtimeDiagnosticSeverity: 'error', + updatedAt: '2026-05-08T12:00:00.000Z', + }, + }); + + expect(payload.memberCardError).toBe( + 'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys' + ); + expect(payload.diagnostics?.[0]).toBe( + 'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys' + ); + expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBe( + 'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys' + ); + expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"'); + }); }); diff --git a/test/shared/utils/anthropicLaunchModel.test.ts b/test/shared/utils/anthropicLaunchModel.test.ts index 330cb77b..d1358234 100644 --- a/test/shared/utils/anthropicLaunchModel.test.ts +++ b/test/shared/utils/anthropicLaunchModel.test.ts @@ -47,16 +47,31 @@ describe('resolveAnthropicLaunchModel', () => { availableLaunchModels: ['opus', 'opus[1m]'], }) ).toBe('opus'); - }); - - it('preserves limitContext requests and never manufactures 1M Haiku variants', () => { expect( resolveAnthropicLaunchModel({ - selectedModel: 'sonnet', - limitContext: true, + selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION, + limitContext: false, + defaultLaunchModel: 'sonnet[1m]', availableLaunchModels: ['sonnet', 'sonnet[1m]'], }) ).toBe('sonnet'); + }); + + it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants', () => { + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'sonnet', + limitContext: false, + availableLaunchModels: ['sonnet', 'sonnet[1m]'], + }) + ).toBe('sonnet'); + expect( + resolveAnthropicLaunchModel({ + selectedModel: 'claude-sonnet-4-6', + limitContext: false, + availableLaunchModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'], + }) + ).toBe('claude-sonnet-4-6'); expect( resolveAnthropicLaunchModel({ selectedModel: 'haiku', diff --git a/test/shared/utils/teamGraphDefaultLayout.test.ts b/test/shared/utils/teamGraphDefaultLayout.test.ts new file mode 100644 index 00000000..3a6ce993 --- /dev/null +++ b/test/shared/utils/teamGraphDefaultLayout.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; + +describe('team graph default layout', () => { + function members(count: number): Array<{ name: string; agentId: string }> { + return Array.from({ length: count }, (_, index) => ({ + name: `member-${index}`, + agentId: `agent-${index}`, + })); + } + + it('seeds six visible owners into two row-orbit rows', () => { + const teamMembers = members(6); + + expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({ + 'agent-0': { ringIndex: 0, sectorIndex: 0 }, + 'agent-1': { ringIndex: 0, sectorIndex: 1 }, + 'agent-2': { ringIndex: 0, sectorIndex: 2 }, + 'agent-3': { ringIndex: 2, sectorIndex: 0 }, + 'agent-4': { ringIndex: 2, sectorIndex: 1 }, + 'agent-5': { ringIndex: 2, sectorIndex: 2 }, + }); + }); + + it('seeds eight visible owners into row-orbit defaults', () => { + const teamMembers = members(8); + + expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({ + 'agent-0': { ringIndex: 0, sectorIndex: 0 }, + 'agent-1': { ringIndex: 0, sectorIndex: 1 }, + 'agent-2': { ringIndex: 0, sectorIndex: 2 }, + 'agent-3': { ringIndex: 1, sectorIndex: 0 }, + 'agent-4': { ringIndex: 1, sectorIndex: 1 }, + 'agent-5': { ringIndex: 2, sectorIndex: 0 }, + 'agent-6': { ringIndex: 2, sectorIndex: 1 }, + 'agent-7': { ringIndex: 2, sectorIndex: 2 }, + }); + }); + + it('seeds twelve visible owners into four row-orbit rows', () => { + const teamMembers = members(12); + + expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({ + 'agent-0': { ringIndex: 0, sectorIndex: 0 }, + 'agent-1': { ringIndex: 0, sectorIndex: 1 }, + 'agent-2': { ringIndex: 0, sectorIndex: 2 }, + 'agent-3': { ringIndex: 1, sectorIndex: 0 }, + 'agent-4': { ringIndex: 1, sectorIndex: 1 }, + 'agent-5': { ringIndex: 1, sectorIndex: 2 }, + 'agent-6': { ringIndex: 2, sectorIndex: 0 }, + 'agent-7': { ringIndex: 2, sectorIndex: 1 }, + 'agent-8': { ringIndex: 2, sectorIndex: 2 }, + 'agent-9': { ringIndex: 3, sectorIndex: 0 }, + 'agent-10': { ringIndex: 3, sectorIndex: 1 }, + 'agent-11': { ringIndex: 3, sectorIndex: 2 }, + }); + }); +}); diff --git a/vite.web.config.ts b/vite.web.config.ts index 39c5778d..02f98aa4 100644 --- a/vite.web.config.ts +++ b/vite.web.config.ts @@ -11,6 +11,7 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as export default defineConfig({ root: resolve(ROOT, 'src/renderer'), + cacheDir: resolve(ROOT, 'node_modules/.vite/web-renderer'), plugins: [react()], server: { host: '127.0.0.1',