fix(team): refine task duration timeline

This commit is contained in:
777genius 2026-05-08 09:58:57 +03:00
parent 8a62aebb3d
commit 9d7542e9c4
7 changed files with 335 additions and 61 deletions

View file

@ -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<string, TeamCreateRequest['members'][number]>();
for (const member of metaMembers) {

View file

@ -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<string, string>;
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 (
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
@ -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 (
<div key={event.id} className="flex">
@ -47,6 +75,19 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
{time}
</span>
<EventContent event={event} memberColorMap={memberColorMap} />
{implementationDuration ? (
<span
className="shrink-0 rounded bg-[var(--color-bg-secondary)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]"
title={
implementationDuration.running
? 'Current implementation interval'
: 'Implementation interval ended at this transition'
}
>
{implementationDuration.running ? 'running ' : ''}
{formatTaskImplementationDuration(implementationDuration.elapsedMs)}
</span>
) : null}
{shouldShowTrailingActor(event) && event.actor ? (
<span className="ml-auto shrink-0">
<MemberBadge
@ -78,7 +119,7 @@ const EventContent = ({
}: {
event: TaskHistoryEvent;
memberColorMap?: Map<string, string>;
}) => {
}): 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;

View file

@ -1374,7 +1374,7 @@ export const TaskDetailDialog = ({
headerExtra={
showTaskImplementationDuration ? (
<span
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
className="inline-flex items-center gap-1 rounded-md bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title="Implementation time from persisted work intervals"
>
<Clock size={10} />
@ -1384,7 +1384,12 @@ export const TaskDetailDialog = ({
}
defaultOpen={false}
>
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
<WorkflowTimeline
events={currentTask.historyEvents}
memberColorMap={colorMap}
implementationDurationTask={currentTask}
nowMs={taskDurationNowMs}
/>
</CollapsibleTeamSection>
) : null}

View file

@ -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<TInterval extends TaskWorkDurationIntervalLike>(
task: TaskWorkDurationLike<TInterval> | null | undefined,
nowMs = Date.now()
@ -69,6 +89,55 @@ export function calculateTaskImplementationDuration<TInterval extends TaskWorkDu
return { elapsedMs, hasRunningInterval, countedIntervalCount: windows.length };
}
export function calculateTaskImplementationEventDuration<
TInterval extends TaskWorkDurationIntervalLike,
>(
task: TaskWorkDurationLike<TInterval> | 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 {

View file

@ -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,

View file

@ -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();

View file

@ -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');