diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1e653293..fbd59f6d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -30796,6 +30796,8 @@ export class TeamProvisioningService { ); } + const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); + try { const allInboxNames = Array.from( new Set( @@ -30820,7 +30822,14 @@ export class TeamProvisioningService { return !inboxNameSetLower.has(match[1].toLowerCase()); }); if (inboxNames.length > 0) { - const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); + const configHasOpenCodeMember = configMembers.some((member) => { + const providerId = normalizeOptionalTeamProviderId(member.providerId); + const model = typeof member.model === 'string' ? member.model.trim() : ''; + return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode'; + }); + if (configHasOpenCodeMember) { + return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId); + } const configMembersByName = new Map( configMembers.map((member) => [member.name.toLowerCase(), member] as const) ); @@ -30878,54 +30887,8 @@ export class TeamProvisioningService { ); } - const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); if (configMembers.length > 0) { - if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) { - return { - level: 'unsafe', - rosterSource: 'config', - members: [], - warnings: [], - blockers: [ - `[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`, - ], - }; - } - const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({ - leadProviderId, - members: configMembers, - hasOpenCodeRuntimeAdapter: true, - }); - if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) { - const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every( - (lane) => - normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' && - typeof lane.member.model === 'string' && - lane.member.model.trim().length > 0 - ); - if (!sideLanesHaveExplicitProviderModels) { - return { - level: 'unsafe', - rosterSource: 'config', - members: [], - warnings: [], - blockers: [ - `[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`, - ], - }; - } - } - return { - level: 'repairable', - 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.', - ], - blockers: [], - repairAction: 'materialize-members-meta', - }; + return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId); } let configParseFailed = false; @@ -30949,6 +30912,59 @@ export class TeamProvisioningService { }; } + private buildConfigLaunchCompatibilityReport( + teamName: string, + configMembers: TeamCreateRequest['members'], + leadProviderId?: TeamProviderId + ): TeamLaunchCompatibilityReport { + if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) { + return { + level: 'unsafe', + rosterSource: 'config', + members: [], + warnings: [], + blockers: [ + `[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`, + ], + }; + } + const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({ + leadProviderId, + members: configMembers, + hasOpenCodeRuntimeAdapter: true, + }); + if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) { + const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every( + (lane) => + normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' && + typeof lane.member.model === 'string' && + lane.member.model.trim().length > 0 + ); + if (!sideLanesHaveExplicitProviderModels) { + return { + level: 'unsafe', + rosterSource: 'config', + members: [], + warnings: [], + blockers: [ + `[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`, + ], + }; + } + } + return { + level: 'repairable', + 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.', + ], + blockers: [], + repairAction: 'materialize-members-meta', + }; + } + private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] { const byName = new Map(); for (const member of metaMembers) { diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index 09f0594d..25c56832 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -6,17 +6,38 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; +import { + calculateTaskImplementationEventDuration, + formatTaskImplementationDuration, +} from '@shared/utils/taskWorkDuration'; import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react'; -import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types'; +import type { + TaskHistoryEvent, + TaskWorkInterval, + TeamReviewState, + TeamTaskStatus, +} from '@shared/types'; interface WorkflowTimelineProps { events: TaskHistoryEvent[]; /** Map of member name → color name for colored badges. */ memberColorMap?: Map; + implementationDurationTask?: { + status?: string | null; + workIntervals?: TaskWorkInterval[] | null; + } | null; + nowMs?: number; } -export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelineProps) => { +export const WorkflowTimeline = ({ + events, + memberColorMap, + implementationDurationTask, + nowMs, +}: WorkflowTimelineProps): React.JSX.Element => { + const implementationNowMs = nowMs ?? 0; + if (events.length === 0) { return (
@@ -30,6 +51,13 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro {events.map((event, idx) => { const isLast = idx === events.length - 1; const time = formatTime(event.timestamp); + const implementationDuration = implementationDurationTask + ? calculateTaskImplementationEventDuration( + implementationDurationTask, + event, + implementationNowMs + ) + : null; return (
@@ -47,6 +75,19 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro {time} + {implementationDuration ? ( + + {implementationDuration.running ? 'running ' : ''} + {formatTaskImplementationDuration(implementationDuration.elapsedMs)} + + ) : null} {shouldShowTrailingActor(event) && event.actor ? ( ; -}) => { +}): React.JSX.Element => { switch (event.type) { case 'task_created': return ( @@ -196,7 +237,7 @@ const EventContent = ({ } }; -const StatusBadge = ({ status }: { status: TeamTaskStatus }) => { +const StatusBadge = ({ status }: { status: TeamTaskStatus }): React.JSX.Element => { const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending; const label = TASK_STATUS_LABELS[status] ?? status; return ( @@ -208,7 +249,7 @@ const StatusBadge = ({ status }: { status: TeamTaskStatus }) => { ); }; -const ReviewStateBadge = ({ state }: { state: TeamReviewState }) => { +const ReviewStateBadge = ({ state }: { state: TeamReviewState }): React.JSX.Element | null => { if (state === 'none') return null; const display = REVIEW_STATE_DISPLAY[state]; if (!display) return null; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index b60ec5f3..88ea4296 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1374,7 +1374,7 @@ export const TaskDetailDialog = ({ headerExtra={ showTaskImplementationDuration ? ( @@ -1384,7 +1384,12 @@ export const TaskDetailDialog = ({ } defaultOpen={false} > - + ) : null} diff --git a/src/shared/utils/taskWorkDuration.ts b/src/shared/utils/taskWorkDuration.ts index c644294f..5e370ca7 100644 --- a/src/shared/utils/taskWorkDuration.ts +++ b/src/shared/utils/taskWorkDuration.ts @@ -3,6 +3,15 @@ interface TaskWorkDurationIntervalLike { completedAt?: string | null; } +interface TaskWorkDurationEventLike { + id?: string | null; + type?: string | null; + timestamp?: string | null; + status?: string | null; + from?: string | null; + to?: string | null; +} + export interface TaskWorkDurationLike< TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike, > { @@ -16,12 +25,23 @@ export interface TaskImplementationDuration { countedIntervalCount: number; } +export interface TaskImplementationEventDuration { + elapsedMs: number; + running: boolean; +} + +const TIMELINE_EVENT_MATCH_TOLERANCE_MS = 5_000; + function parseIsoMs(value: string | null | undefined): number { if (!value) return 0; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } +function isNearTime(leftMs: number, rightMs: number): boolean { + return Math.abs(leftMs - rightMs) <= TIMELINE_EVENT_MATCH_TOLERANCE_MS; +} + export function calculateTaskImplementationDuration( task: TaskWorkDurationLike | null | undefined, nowMs = Date.now() @@ -69,6 +89,55 @@ export function calculateTaskImplementationDuration( + task: TaskWorkDurationLike | null | undefined, + event: TaskWorkDurationEventLike, + nowMs = Date.now() +): TaskImplementationEventDuration | null { + if (!task || !Array.isArray(task.workIntervals)) return null; + + const eventMs = parseIsoMs(event.timestamp); + if (eventMs <= 0) return null; + + if ( + event.type === 'status_changed' && + event.from === 'in_progress' && + event.to !== 'in_progress' + ) { + let closest: { elapsedMs: number; distanceMs: number } | null = null; + + for (const interval of task.workIntervals) { + const startMs = parseIsoMs(interval?.startedAt); + const endMs = parseIsoMs(interval?.completedAt); + if (startMs <= 0 || endMs <= startMs || !isNearTime(endMs, eventMs)) continue; + + const distanceMs = Math.abs(endMs - eventMs); + if (!closest || distanceMs < closest.distanceMs) { + closest = { elapsedMs: endMs - startMs, distanceMs }; + } + } + + return closest ? { elapsedMs: closest.elapsedMs, running: false } : null; + } + + const startsInProgress = + (event.type === 'task_created' && event.status === 'in_progress') || + (event.type === 'status_changed' && event.to === 'in_progress'); + + if (!startsInProgress || task.status !== 'in_progress') return null; + + for (const interval of task.workIntervals) { + const startMs = parseIsoMs(interval?.startedAt); + if (startMs > 0 && !interval?.completedAt && nowMs > startMs && isNearTime(startMs, eventMs)) { + return { elapsedMs: nowMs - startMs, running: true }; + } + } + + return null; +} + export function shouldShowTaskImplementationDuration( duration: TaskImplementationDuration ): boolean { diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index 022805b5..8e97fb37 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -153,7 +153,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => { ]); }); - it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => { + it('rejects inbox fallback when OpenCode metadata is incomplete without members.meta truth', async () => { const svc = new TeamProvisioningService( {} as never, { listInboxNames: vi.fn(async () => ['tom']) } as never, @@ -163,7 +163,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => { const configRaw = JSON.stringify({ name: 't', - members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }], + members: [{ name: 'tom', role: 'developer', provider: 'opencode' }], }); await expect( @@ -195,6 +195,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => { ]); }); + it('prefers complete mixed OpenCode config over inbox names when members.meta is missing', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => ['tom']) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }], + }); + + const report = await (svc as unknown as any).probeLaunchCompatibility('t', configRaw, 'codex'); + expect(report).toMatchObject({ + level: 'repairable', + rosterSource: 'config', + repairAction: 'materialize-members-meta', + }); + }); + it('rejects mixed OpenCode config fallback when the side lane is missing an explicit model', async () => { const svc = new TeamProvisioningService( {} as never, diff --git a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts index ae68871f..34250c80 100644 --- a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts +++ b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts @@ -80,7 +80,9 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({ ? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra) : null ), - title === 'Changes' && open ? React.createElement('div', null, children) : null + (title === 'Changes' || title === 'Workflow History') && open + ? React.createElement('div', null, children) + : null ); }, })); @@ -561,7 +563,7 @@ describe('TaskDetailDialog changes summary loading', () => { }); }); - it('shows total implementation time in the workflow history header', async () => { + it('shows total and per-transition implementation time in workflow history', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z')); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -578,9 +580,33 @@ describe('TaskDetailDialog changes summary loading', () => { historyEvents: [ { id: 'event-created', - timestamp: '2026-04-20T10:00:00.000Z', + timestamp: '2026-04-20T09:59:00.000Z', type: 'task_created', - status: 'in_progress', + status: 'pending', + actor: 'lead', + }, + { + id: 'event-started', + timestamp: '2026-04-20T10:00:00.000Z', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + actor: 'lead', + }, + { + id: 'event-completed', + timestamp: '2026-04-20T10:02:31.000Z', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + actor: 'alice', + }, + { + id: 'event-restarted', + timestamp: '2026-04-20T10:05:00.000Z', + type: 'status_changed', + from: 'completed', + to: 'in_progress', actor: 'lead', }, ], @@ -607,6 +633,21 @@ describe('TaskDetailDialog changes summary loading', () => { expect(host.textContent).toContain('Workflow History'); expect(host.textContent).toContain('Work time 5m 00s'); + const workflowButton = [...host.querySelectorAll('button')].find( + (button) => button.textContent?.startsWith('Workflow History') === true + ); + if (!workflowButton) { + throw new Error('Workflow History section button not found'); + } + + await act(async () => { + workflowButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('2m 30s'); + expect(host.textContent).toContain('running 2m 30s'); + await act(async () => { root.unmount(); await Promise.resolve(); diff --git a/test/shared/utils/taskWorkDuration.test.ts b/test/shared/utils/taskWorkDuration.test.ts index ecfde4da..3e6d60b7 100644 --- a/test/shared/utils/taskWorkDuration.test.ts +++ b/test/shared/utils/taskWorkDuration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + calculateTaskImplementationEventDuration, calculateTaskImplementationDuration, formatTaskImplementationDuration, shouldShowTaskImplementationDuration, @@ -74,6 +75,86 @@ describe('taskWorkDuration', () => { expect(duration.countedIntervalCount).toBe(2); }); + it('matches a closed interval to the status transition that ended implementation', () => { + const duration = calculateTaskImplementationEventDuration( + { + status: 'completed', + workIntervals: [ + { + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T10:02:30.000Z', + }, + ], + }, + { + id: 'event-completed', + timestamp: '2026-05-08T10:02:32.000Z', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + } + ); + + expect(duration).toEqual({ elapsedMs: 150_000, running: false }); + }); + + it('shows a running interval only on the event that started the active implementation', () => { + const task = { + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:05:00.000Z' }], + }; + + expect( + calculateTaskImplementationEventDuration( + task, + { + id: 'event-started', + timestamp: '2026-05-08T10:05:00.000Z', + type: 'status_changed', + from: 'completed', + to: 'in_progress', + }, + Date.parse('2026-05-08T10:07:30.000Z') + ) + ).toEqual({ elapsedMs: 150_000, running: true }); + + expect( + calculateTaskImplementationEventDuration( + task, + { + id: 'event-created', + timestamp: '2026-05-08T10:00:00.000Z', + type: 'task_created', + status: 'pending', + }, + Date.parse('2026-05-08T10:07:30.000Z') + ) + ).toBeNull(); + }); + + it('does not derive transition durations from history gaps without a matching work interval', () => { + const duration = calculateTaskImplementationEventDuration( + { + status: 'completed', + workIntervals: [ + { + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T10:02:30.000Z', + }, + ], + }, + { + id: 'event-comment', + timestamp: '2026-05-08T10:20:00.000Z', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + } + ); + + expect(duration).toBeNull(); + }); + it('formats seconds, minutes, and hours for compact UI labels', () => { expect(formatTaskImplementationDuration(42_900)).toBe('42s'); expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');