diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 6205bcc2..da3d4541 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -35,6 +35,7 @@ import { type InProgressRow, } from '@/components/mission-control/InProgressPanel'; import { NeedsInputPanel, selectNeedsInputRows } from '@/components/mission-control/NeedsInputPanel'; +import { CompletedPanel, type CompletedWorkRow } from '@/components/mission-control/CompletedPanel'; import type { SliceDetailTarget } from '@/components/mission-control/SliceDetailModal'; import { PremiumCard } from '@/components/shared/PremiumCard'; import { EntityIcon, type EntityIconType } from '@/components/shared/EntityIcon'; @@ -382,8 +383,9 @@ function isSyntheticActivityItem(item: LiveActivityItem): boolean { /** Items emitted by mock autopilot workers during test harness runs. */ function isMockActivityItem(item: LiveActivityItem): boolean { - const meta = (item as any).metadata; - return meta != null && typeof meta === 'object' && meta.mock === true; + const meta = (item as { metadata?: unknown }).metadata; + if (meta == null || typeof meta !== 'object') return false; + return (meta as { mock?: unknown }).mock === true; } function isConfigureEngineeringAgentIntent(value: string): boolean { @@ -788,7 +790,9 @@ function DashboardShell({ setInProgressSubFilter('needs_attention'); } }, []); - const [inProgressSubFilter, setInProgressSubFilter] = useState<'all' | 'needs_attention'>('all'); + const [inProgressSubFilter, setInProgressSubFilter] = useState< + 'all' | 'needs_attention' | 'completed' + >('all'); const actionableSliceRuns = useMemo( () => (Array.isArray(data.sliceRuns) ? data.sliceRuns : []), [data.sliceRuns] @@ -807,6 +811,91 @@ function DashboardShell({ [actionableSliceRuns] ); const needsInputCount = needsInputRows.length + (decisionsVisible ? data.decisions.length : 0); + const completedRows = useMemo(() => { + const completedSlices = actionableSliceRuns.filter((slice) => { + const status = normalizeStatus(slice.status); + return status === 'completed' || status === 'archived'; + }); + if (completedSlices.length === 0) return []; + + const sessionByRunId = new Map(); + for (const session of sessionNodesInScope) { + const runId = session.runId?.trim(); + if (runId && !sessionByRunId.has(runId)) { + sessionByRunId.set(runId, session); + } + const id = session.id?.trim(); + if (id && !sessionByRunId.has(id)) { + sessionByRunId.set(id, session); + } + } + + const initiativeTitleById = new Map(); + for (const session of sessionNodesInScope) { + const id = session.initiativeId?.trim(); + const label = session.groupLabel?.trim(); + if (!id || !label || initiativeTitleById.has(id)) continue; + initiativeTitleById.set(id, label); + } + + const rows: CompletedWorkRow[] = []; + for (const slice of completedSlices) { + const runId = (slice.runId ?? slice.sliceRunId ?? '').trim(); + if (!runId) continue; + + const session = + sessionByRunId.get(runId) ?? + sessionByRunId.get(slice.sliceRunId) ?? + null; + const initiativeId = slice.initiativeId ?? session?.initiativeId ?? null; + const timelineEvents = activityInScope + .filter((item) => { + const itemRunId = resolveActivityRunId(item); + const itemSliceRunId = resolveActivitySliceRunId(item); + if (itemRunId && (itemRunId === runId || itemRunId === slice.sliceRunId)) return true; + if (itemSliceRunId && itemSliceRunId === slice.sliceRunId) return true; + return false; + }) + .sort((a, b) => toEpoch(a.timestamp) - toEpoch(b.timestamp)); + + rows.push({ + key: `completed:${slice.sliceRunId}`, + runId, + title: + slice.workstreamTitle ?? + session?.title ?? + `Completed slice ${slice.sliceRunId.slice(0, 8)}`, + statusExplainer: + slice.statusExplainer ?? + slice.lastEventSummary ?? + session?.lastEventSummary ?? + null, + initiativeTitle: initiativeId ? initiativeTitleById.get(initiativeId) ?? initiativeId : null, + workstreamTitle: slice.workstreamTitle ?? session?.title ?? null, + scope: slice.scope ?? null, + taskIds: Array.isArray(slice.taskIds) ? slice.taskIds : [], + milestoneIds: Array.isArray(slice.milestoneIds) ? slice.milestoneIds : [], + artifacts: Array.isArray(slice.artifacts) ? slice.artifacts : [], + artifactCount: slice.artifactCount ?? 0, + completedAt: + slice.completedAt ?? + slice.updatedAt ?? + slice.lastEventAt ?? + session?.updatedAt ?? + session?.lastEventAt ?? + null, + timelineEvents, + }); + } + + rows.sort((a, b) => toEpoch(b.completedAt) - toEpoch(a.completedAt)); + return rows; + }, [ + actionableSliceRuns, + activityInScope, + sessionNodesInScope, + ]); + const completedInProgressCount = completedRows.length; const [sliceDetailTarget, setSliceDetailTarget] = useState(null); @@ -3185,6 +3274,19 @@ function DashboardShell({ Needs attention {needsInputCount} + ) : null} @@ -3290,6 +3392,12 @@ function DashboardShell({ + ) : inProgressSubFilter === 'completed' ? ( + setInitiativesSidebarTab('next_up')} + /> ) : ( | undefined): StructuredStatusUpdate[] { + if (!metadata) return []; + + const result = asMetadataRecord(metadata.result); + const sources = [metadata, result].filter( + (entry): entry is Record => Boolean(entry) + ); + const updates: StructuredStatusUpdate[] = []; + const dedupe = new Set(); + + const pushUpdates = (scope: StructuredStatusUpdate['scope'], raw: unknown) => { + if (!Array.isArray(raw)) return; + for (const candidate of raw) { + const record = asMetadataRecord(candidate); + if (!record) continue; + const idKey = scope === 'Task' ? ['task_id', 'taskId', 'id'] : ['milestone_id', 'milestoneId', 'id']; + const titleKey = scope === 'Task' + ? ['task_title', 'taskTitle', 'title', 'name'] + : ['milestone_title', 'milestoneTitle', 'title', 'name']; + const id = metadataString(record, idKey); + const rawLabel = + metadataString(record, titleKey) ?? + (id && !isOpaqueId(id) ? id : null) ?? + scope; + const label = readableContextLabel(rawLabel, id) ?? humanizeText(rawLabel); + const status = metadataString(record, ['status', 'state']); + const reason = humanizeActivityBody( + metadataString(record, ['reason', 'summary', 'description', 'note']) + ); + const key = `${scope}|${label.toLowerCase()}|${(status ?? '').toLowerCase()}|${(reason ?? '').toLowerCase()}`; + if (dedupe.has(key)) continue; + dedupe.add(key); + updates.push({ + scope, + label, + status: status ? humanizeText(status) : null, + reason, + }); + } + }; + + for (const source of sources) { + pushUpdates('Task', source.task_updates ?? source.taskUpdates); + pushUpdates('Milestone', source.milestone_updates ?? source.milestoneUpdates); + } + + return updates; +} + +function summarizeStatusUpdatesForCard(item: LiveActivityItem): string | null { + const metadata = metadataForItem(item); + if (!metadata) return null; + + const eventName = metadataString(metadata, ['event', 'event_name', 'eventName'])?.toLowerCase() ?? ''; + const updates = extractStructuredStatusUpdates(metadata); + const statusUpdatesApplied = countFromValue( + metadata.status_updates_applied ?? metadata.statusUpdatesApplied + ); + const bufferedRaw = metadata.status_updates_buffered ?? metadata.statusUpdatesBuffered; + const isBuffered = + eventName.includes('status_updates_buffered') || + bufferedRaw === true || + (typeof bufferedRaw === 'number' && bufferedRaw > 0) || + (typeof bufferedRaw === 'string' && ['true', '1', 'yes'].includes(bufferedRaw.trim().toLowerCase())); + + if (isBuffered) { + if (updates.length > 0) { + const first = updates[0]; + return `Queued ${updates.length} update${updates.length === 1 ? '' : 's'}: ${first.label}${first.status ? ` → ${first.status}` : ''}`; + } + if (statusUpdatesApplied !== null && statusUpdatesApplied > 0) { + return `Queued ${statusUpdatesApplied} status update${statusUpdatesApplied === 1 ? '' : 's'} for sync`; + } + return 'Queued status updates for sync'; + } + + if (statusUpdatesApplied !== null && statusUpdatesApplied > 0) { + return `Applied ${statusUpdatesApplied} status update${statusUpdatesApplied === 1 ? '' : 's'}`; + } + + return null; +} + +function hasStructuredOutcomesData(item: LiveActivityItem | null): boolean { + if (!item) return false; + const metadata = metadataForItem(item); + if (!metadata) return false; + const outcomes = asMetadataRecord(metadata.outcomes); + const tests = asMetadataRecord(outcomes?.tests); + const updates = extractStructuredStatusUpdates(metadata); + const statusUpdatesApplied = countFromValue( + metadata.status_updates_applied ?? metadata.statusUpdatesApplied + ); + const bufferedRaw = metadata.status_updates_buffered ?? metadata.statusUpdatesBuffered; + const hasBufferedSignal = + bufferedRaw === true || + (typeof bufferedRaw === 'number' && bufferedRaw > 0) || + (typeof bufferedRaw === 'string' && ['true', '1', 'yes'].includes(bufferedRaw.trim().toLowerCase())); + const hasOutcomeSignals = Boolean( + outcomes?.pr_url || + outcomes?.pr_number || + outcomes?.commit_sha || + outcomes?.commit_url || + tests || + outcomes?.artifacts + ); + return hasOutcomeSignals || updates.length > 0 || (statusUpdatesApplied ?? 0) > 0 || hasBufferedSignal; +} + function looksLikeFilesystemPath(value: string): boolean { const trimmed = value.trim(); if (!trimmed) return false; @@ -1739,17 +1857,51 @@ function metadataString( return null; } +const UUID_INLINE_REGEX = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; +const LONG_HEX_INLINE_REGEX = /\b[0-9a-f]{20,}\b/gi; + +function scrubOpaqueIdsFromContext(value: string): string { + let cleaned = value + .replace(UUID_INLINE_REGEX, '') + .replace(LONG_HEX_INLINE_REGEX, '') + .replace(/\[workstream\b[^\]]*\]/gi, '') + .replace(/\(\s*\)/g, '') + .replace(/\[\s*\]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); + + cleaned = cleaned + .replace(/(?:\s*(?:>|\/)\s*){2,}/g, ' > ') + .replace(/^\s*(?:>|\/)\s*/g, '') + .replace(/\s*(?:>|\/)\s*$/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); + + return cleaned; +} + +function compactAfterIdScrub(value: string): string { + return value + .replace(/\bfor\s+slice\b/gi, '') + .replace(/\bfor\s+run\b/gi, '') + .replace(/\bwith\s+slice\b/gi, '') + .replace(/\s{2,}/g, ' ') + .replace(/\s*(?:-|:|>)\s*$/g, '') + .trim(); +} + function readableContextLabel( value: string | null | undefined, idHint?: string | null ): string | null { if (typeof value !== 'string') return null; - const trimmed = value.trim(); + const trimmed = scrubOpaqueIdsFromContext(value.trim()); if (!trimmed) return null; const normalizedId = typeof idHint === 'string' ? idHint.trim().toLowerCase() : ''; if (normalizedId && trimmed.toLowerCase() === normalizedId) return null; if (isOpaqueId(trimmed)) return null; - return trimmed; + const humanized = humanizeText(trimmed).trim(); + return humanized.length > 0 ? humanized : null; } function firstReadableContextLabel( @@ -2604,6 +2756,8 @@ function renderArtifactValue(value: unknown): ReactNode { } if (value && typeof value === 'object') { + const narrative = renderArtifactNarrative(value); + if (narrative) return narrative; const entries = Object.entries(value as Record); return (
@@ -2630,6 +2784,279 @@ function renderArtifactValue(value: unknown): ReactNode { return

No artifact payload.

; } +type ArtifactNarrative = { + summary: string | null; + updatesApplied: string[]; + artifactsCreated: Array<{ + title: string; + type: string | null; + url: string | null; + }>; + nextUp: string[]; +}; + +function artifactHref(url: string | null): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^file:\/\//i.test(trimmed)) return null; + if (!looksLikeFilesystemPath(trimmed)) return null; + return resolveFileEvidenceHref(trimmed); +} + +function collectArtifactNarrative(value: unknown): ArtifactNarrative | null { + const root = asMetadataRecord(value); + if (!root) return null; + + const result = asMetadataRecord(root.result); + const outcomes = asMetadataRecord(root.outcomes); + const sources = [result, outcomes, root].filter( + (entry): entry is Record => Boolean(entry) + ); + + const firstUnknown = (keys: string[]): unknown => { + for (const source of sources) { + for (const key of keys) { + if (!(key in source)) continue; + const candidate = source[key]; + if (candidate !== undefined && candidate !== null) return candidate; + } + } + return null; + }; + + const firstString = (keys: string[]): string | null => { + for (const source of sources) { + const candidate = metadataString(source, keys); + if (candidate) return candidate; + } + return null; + }; + + const summary = humanizeActivityBody( + firstString(['summary', 'user_summary', 'description', 'message']) + ); + + const updatesApplied: string[] = []; + const seenUpdates = new Set(); + const pushUpdate = (line: string | null) => { + if (!line) return; + const trimmed = line.trim(); + if (!trimmed) return; + const normalized = trimmed.toLowerCase(); + if (seenUpdates.has(normalized)) return; + seenUpdates.add(normalized); + updatesApplied.push(trimmed); + }; + + const parseEntityUpdates = (raw: unknown, label: 'Task' | 'Milestone') => { + if (!Array.isArray(raw)) return; + for (const entry of raw) { + const record = asMetadataRecord(entry); + if (!record) continue; + const id = metadataString(record, [`${label.toLowerCase()}_id`, `${label.toLowerCase()}Id`, 'id']); + const title = + metadataString(record, [`${label.toLowerCase()}_title`, `${label.toLowerCase()}Title`, 'title', 'name']) ?? + (id && !isOpaqueId(id) ? id : null); + const status = metadataString(record, ['status', 'state']); + const reason = humanizeActivityBody( + metadataString(record, ['reason', 'summary', 'description', 'note']) + ); + const subject = title ? readableContextLabel(title, id) ?? humanizeText(title) : label; + const statusLabel = status ? humanizeText(status) : null; + const parts = [subject]; + if (statusLabel) parts.push(`→ ${statusLabel}`); + if (reason) parts.push(`· ${reason}`); + pushUpdate(parts.join(' ')); + } + }; + + for (const source of sources) { + parseEntityUpdates(source.task_updates ?? source.taskUpdates, 'Task'); + parseEntityUpdates(source.milestone_updates ?? source.milestoneUpdates, 'Milestone'); + } + + const statusUpdatesApplied = countFromValue( + firstUnknown(['status_updates_applied', 'statusUpdatesApplied']) + ); + if (statusUpdatesApplied !== null && statusUpdatesApplied > 0) { + pushUpdate( + `${statusUpdatesApplied} status update${statusUpdatesApplied === 1 ? '' : 's'} applied` + ); + } + + const statusBufferedRaw = firstUnknown(['status_updates_buffered', 'statusUpdatesBuffered']); + const statusBufferedCount = countFromValue(statusBufferedRaw); + const statusBufferedBool = statusBufferedRaw === true; + if (statusBufferedBool || (statusBufferedCount !== null && statusBufferedCount > 0)) { + pushUpdate('Status updates buffered for sync'); + } + + const nextUp: string[] = []; + const seenNext = new Set(); + const pushNext = (candidate: string | null | undefined) => { + const label = humanizeActivityBody(candidate); + if (!label) return; + const normalized = label.toLowerCase(); + if (seenNext.has(normalized)) return; + seenNext.add(normalized); + nextUp.push(label); + }; + + pushNext(firstString(['next_step', 'nextStep'])); + for (const source of sources) { + const raw = source.next_actions ?? source.nextActions; + if (!Array.isArray(raw)) continue; + for (const entry of raw) { + if (typeof entry === 'string') pushNext(entry); + } + } + + const artifactsCreated: ArtifactNarrative['artifactsCreated'] = []; + const seenArtifacts = new Set(); + const pushArtifact = (title: string, type: string | null, url: string | null) => { + const normalized = `${title.toLowerCase()}|${(type ?? '').toLowerCase()}|${url ?? ''}`; + if (seenArtifacts.has(normalized)) return; + seenArtifacts.add(normalized); + artifactsCreated.push({ title, type, url }); + }; + + const parseArtifacts = (raw: unknown) => { + const list = Array.isArray(raw) ? raw : raw ? [raw] : []; + for (const candidate of list) { + const record = asMetadataRecord(candidate); + if (!record) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + const humanized = humanizeActivityBody(candidate) ?? humanizeText(candidate); + if (humanized) pushArtifact(humanized, null, null); + } + continue; + } + const type = metadataString(record, ['artifact_type', 'artifactType', 'type']); + const rawUrl = metadataString(record, ['url', 'source_pointer', 'sourcePointer', 'path']); + const title = + metadataString(record, ['name', 'title', 'artifact_title', 'artifactTitle']) ?? + (rawUrl ? humanizePath(rawUrl) : humanizeArtifactType(type)); + pushArtifact(humanizeText(title), type, rawUrl); + } + }; + + for (const source of sources) { + parseArtifacts(source.artifacts ?? source.artifact); + } + + const singleArtifactType = firstString(['artifact_type', 'artifactType']); + const singleArtifactUrl = firstString(['url', 'artifact_url', 'artifactUrl']); + if (artifactsCreated.length === 0 && (singleArtifactType || singleArtifactUrl)) { + const title = singleArtifactUrl ? humanizePath(singleArtifactUrl) : humanizeArtifactType(singleArtifactType); + pushArtifact(humanizeText(title), singleArtifactType, singleArtifactUrl); + } + + if (!summary && updatesApplied.length === 0 && artifactsCreated.length === 0 && nextUp.length === 0) { + return null; + } + + return { + summary, + updatesApplied, + artifactsCreated, + nextUp, + }; +} + +function renderArtifactNarrative(value: unknown): ReactNode | null { + const narrative = collectArtifactNarrative(value); + if (!narrative) return null; + + return ( +
+ {narrative.summary && ( +
+

+ What Changed +

+

{narrative.summary}

+
+ )} + + {narrative.updatesApplied.length > 0 && ( +
+

+ Updates Applied +

+
    + {narrative.updatesApplied.slice(0, 6).map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ )} + + {narrative.artifactsCreated.length > 0 && ( +
+

+ Artifacts Created +

+
+ {narrative.artifactsCreated.slice(0, 5).map((entry) => { + const href = artifactHref(entry.url); + return ( +
+
+

{entry.title}

+ {entry.type && ( + + {humanizeArtifactType(entry.type)} + + )} +
+ {entry.url && ( + href ? ( + + {humanizePath(entry.url)} + + ) : ( +

{humanizePath(entry.url)}

+ ) + )} +
+ ); + })} +
+
+ )} + + {narrative.nextUp.length > 0 && ( +
+

+ Next Up +

+
    + {narrative.nextUp.slice(0, 4).map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ )} +
+ ); +} + function humanizeActivityBody(text: string | null | undefined): string | null { if (!text) return null; const trimmed = text.trim(); @@ -2688,6 +3115,13 @@ function narrativeActivityTitle(item: LiveActivityItem): string | null { case 'autopilot_slice_dispatched': case 'autopilot_slice_started': return `Working on ${taskTitle ?? 'queued task'}`; + case 'autopilot_slice_status_updates_buffered': { + const updates = extractStructuredStatusUpdates(metadata); + if (updates.length > 0) { + return `Applying ${updates.length} status update${updates.length === 1 ? '' : 's'}`; + } + return 'Applying status updates'; + } case 'autopilot_slice_result': case 'autopilot_slice_finished': { if (parsedStatus === 'completed' || parsedStatus === 'success') { @@ -2755,7 +3189,8 @@ function narrativeActivityTitle(item: LiveActivityItem): string | null { function modalSectionsForEvent( eventName: string, hasArtifact: boolean, - hasOutcome: boolean + hasOutcome: boolean, + hasStructuredOutcomes: boolean ): Set { const sections = new Set(['header']); @@ -2772,6 +3207,9 @@ function modalSectionsForEvent( sections.add('evidence'); sections.add('structured_outcomes'); } + if (hasStructuredOutcomes || eventName.includes('status_updates')) { + sections.add('structured_outcomes'); + } // Blocked/attention events get action_needed if ( @@ -2810,7 +3248,7 @@ function cleanSystemTitle(item: LiveActivityItem): { title: string; isSystem: bo const raw = item.title ?? ''; if (!isSystemNoise(raw)) { - const humanized = humanizeText(raw); + const humanized = compactAfterIdScrub(humanizeText(scrubOpaqueIdsFromContext(raw))); if (humanized && humanized !== 'Untitled session') { return { title: humanized, isSystem: false }; } @@ -4142,7 +4580,8 @@ export const ActivityTimeline = memo(function ActivityTimeline({ return modalSectionsForEvent( eventName, !!activeArtifact || !!extractArtifactId(activeDecorated.item), - !!activeOutcome + !!activeOutcome, + hasStructuredOutcomesData(activeDecorated.item) ); }, [activeDecorated, activeArtifact, activeOutcome, devMode]); const activeResultItems = useMemo(() => { @@ -4264,6 +4703,40 @@ export const ActivityTimeline = memo(function ActivityTimeline({ ), [activeDecorated] ); + + const activeMilestoneBreakdown = useMemo((): MilestoneBreakdownEntry[] => { + if (!activeDecorated) return []; + const runId = resolveRunId(activeDecorated.item); + // From slice run scopeProgress milestones + if (runId) { + const sliceRun = sliceRuns.find( + (s) => s.runId === runId || s.sliceRunId === runId + ); + if (sliceRun?.scopeProgress?.milestones) { + return sliceRun.scopeProgress.milestones.map((ms) => ({ + id: ms.id, + title: ms.title, + tasks: [], + totalTasks: ms.total, + doneTasks: ms.done, + })); + } + } + // From next up queue item + const workstreamId = metadataString( + metadataForItem(activeDecorated.item), + ['workstream_id', 'workstreamId'] + ); + if (workstreamId && nextUpQueue) { + const queueMatch = nextUpQueue.find( + (q) => q.workstreamId === workstreamId + ); + if ((queueMatch as any)?.milestoneBreakdown) { + return (queueMatch as any).milestoneBreakdown; + } + } + return []; + }, [activeDecorated, sliceRuns, nextUpQueue]); const activeResolvedMetadataJson = useMemo( () => metadataToJson(activeMetadata), [activeMetadata] @@ -4696,8 +5169,9 @@ export const ActivityTimeline = memo(function ActivityTimeline({ const isRecent = sortOrder === 'newest' && index < 2; const runId = decorated.runId; const syncSummary = syncReplaySummary(item); + const updatesSummary = summarizeStatusUpdatesForCard(item); const { title: displayTitle } = cleanSystemTitle(item); - const displaySummary = syncSummary ?? humanizeActivityBody(item.summary); + const displaySummary = syncSummary ?? updatesSummary ?? humanizeActivityBody(item.summary); const displayDesc = humanizeActivityBody(item.description); const headline = summarizeDetailHeadline(item, displaySummary ?? displayDesc ?? null); const metadata = metadataForItem(item); @@ -4728,11 +5202,15 @@ export const ActivityTimeline = memo(function ActivityTimeline({ }, ]); const breadcrumb = [initiativeName, workstreamName].filter(Boolean).join(' > '); + const eventContextLabel = readableContextLabel( + metadataString(metadata, ['event', 'event_name', 'eventName']), + null + ); // Enrich context with queue position when autopilot is active const queueInfo = workstreamId ? queueByWorkstream.get(workstreamId) : undefined; const isActiveInQueue = autopilotState?.state === 'running' && workstreamId === autopilotState?.activeWorkstreamId; const queueSuffix = queueInfo ? ` — #${queueInfo.rank} in queue` : ''; - const contextLabel = (breadcrumb || initiativeName || workstreamName || humanizeText(item.type)) + const contextLabel = (breadcrumb || initiativeName || workstreamName || eventContextLabel || labelForType(item.type)) + (isActiveInQueue ? queueSuffix : ''); const primaryTag = userStateLabel(decorated.userState); const timeLabel = new Date(item.timestamp).toLocaleTimeString([], { @@ -5975,21 +6453,70 @@ export const ActivityTimeline = memo(function ActivityTimeline({ {(() => { if (!activeVisibleSections.has('structured_outcomes')) return null; const metadata = metadataForItem(activeDecorated.item); - const outcomes = metadata?.outcomes as Record | undefined; - const taskUpdates = metadata?.task_updates as Array<{ title?: string; status?: string }> | undefined; - if (!outcomes && (!taskUpdates || taskUpdates.length === 0)) return null; - const prUrl = outcomes?.pr_url as string | undefined; + const outcomes = asMetadataRecord(metadata?.outcomes); + const statusUpdates = extractStructuredStatusUpdates(metadata); + const statusUpdatesApplied = countFromValue( + metadata?.status_updates_applied ?? + metadata?.statusUpdatesApplied ?? + asMetadataRecord(metadata?.result)?.status_updates_applied ?? + asMetadataRecord(metadata?.result)?.statusUpdatesApplied + ); + const eventName = metadataString(metadata, ['event', 'event_name', 'eventName'])?.toLowerCase() ?? ''; + const statusBufferedRaw = metadata?.status_updates_buffered ?? metadata?.statusUpdatesBuffered; + const statusBuffered = + eventName.includes('status_updates_buffered') || + statusBufferedRaw === true || + (typeof statusBufferedRaw === 'number' && statusBufferedRaw > 0) || + (typeof statusBufferedRaw === 'string' && ['true', '1', 'yes'].includes(statusBufferedRaw.trim().toLowerCase())); + const prUrl = metadataString(outcomes, ['pr_url', 'prUrl', 'url']); const prNumber = outcomes?.pr_number as string | number | undefined; - const commitSha = outcomes?.commit_sha as string | undefined; - const commitUrl = outcomes?.commit_url as string | undefined; - const tests = outcomes?.tests as { passed?: number; failed?: number; skipped?: number } | undefined; - const hasAny = prUrl || commitSha || tests || (taskUpdates && taskUpdates.length > 0); + const commitSha = metadataString(outcomes, ['commit_sha', 'commitSha']); + const commitUrl = metadataString(outcomes, ['commit_url', 'commitUrl']); + const tests = asMetadataRecord(outcomes?.tests) as { passed?: number; failed?: number; skipped?: number } | null; + const hasAny = + prUrl || + commitSha || + tests || + statusUpdates.length > 0 || + (statusUpdatesApplied ?? 0) > 0 || + statusBuffered; if (!hasAny) return null; return (

- Outcomes + Updates & outcomes

+ {(statusUpdates.length > 0 || (statusUpdatesApplied ?? 0) > 0 || statusBuffered) && ( +
+
+ +
+

+ {statusBuffered ? 'Updates being applied' : 'Updates applied'} +

+

+ {statusUpdates.length > 0 + ? `${statusUpdates.length} scoped update${statusUpdates.length === 1 ? '' : 's'}` + : (statusUpdatesApplied ?? 0) > 0 + ? `${statusUpdatesApplied} status update${statusUpdatesApplied === 1 ? '' : 's'}` + : 'Status updates recorded'} + {statusBuffered ? ' · queued for sync' : ''} +

+
+
+ {statusUpdates.length > 0 && ( +
    + {statusUpdates.slice(0, 6).map((update, i) => ( +
  • + {update.scope}: {update.label} + {update.status ? ` → ${update.status}` : ''} + {update.reason ? ` · ${update.reason}` : ''} +
  • + ))} +
+ )} +
+ )} {prUrl && (
)} - {taskUpdates && taskUpdates.length > 0 && ( -
-
- -
-

Task Updates

-

{taskUpdates.length} task{taskUpdates.length !== 1 ? 's' : ''} updated

-
-
- {taskUpdates.length <= 5 && ( -
    - {taskUpdates.map((tu, i) => ( -
  • - {tu.title ? `"${tu.title}"` : 'Task'}{tu.status ? ` \u2192 ${humanizeText(tu.status)}` : ''} -
  • - ))} -
- )} -
- )} ); })()} @@ -6205,19 +6712,6 @@ export const ActivityTimeline = memo(function ActivityTimeline({ )} - {/* Session status — compact inline */} -
-
-

- Session -

- - {humanizeStopReason(activeAutopilotContext.event) ?? humanizeText(activeAutopilotContext.event)} - -
-
- - {/* Lifecycle trail — horizontal stepper */} {(() => { if (!activeVisibleSections.has('session')) return null; const lcMeta = metadataForItem(activeDecorated.item); @@ -6235,16 +6729,20 @@ export const ActivityTimeline = memo(function ActivityTimeline({ needs_attention: 'Running', failed: 'Running', }; - const currentStep = statusToStep[parsedStatus ?? ''] ?? statusToStep[activeAutopilotContext.event ?? ''] ?? null; - if (!currentStep) return null; - const currentIndex = steps.indexOf(currentStep); + const currentStep = + statusToStep[parsedStatus ?? ''] ?? + statusToStep[activeAutopilotContext.event ?? ''] ?? + null; + const currentIndex = currentStep ? steps.indexOf(currentStep) : -1; const isTerminal = parsedStatus === 'completed' || parsedStatus === 'success'; const activeRunId = resolveRunId(activeDecorated.item); const timing = activeRunId ? sliceTimingByRunId.get(activeRunId) : null; const startMs = timing?.startedAt ? new Date(timing.startedAt).getTime() : null; // elapsedTick forces re-render every second for live elapsed counter void elapsedTick; - const endMs = timing?.completedAt ? new Date(timing.completedAt).getTime() : (isTerminal ? null : Date.now()); + const endMs = timing?.completedAt + ? new Date(timing.completedAt).getTime() + : (isTerminal ? null : Date.now()); let elapsedLabel: string | null = null; if (startMs && endMs) { const elapsed = endMs - startMs; @@ -6258,70 +6756,93 @@ export const ActivityTimeline = memo(function ActivityTimeline({ : `${Math.floor(elapsed / 60_000)}m ${Math.round((elapsed % 60_000) / 1000)}s`; } return ( -
- {steps.map((step, i) => { - const isDone = i < currentIndex || (i === currentIndex && isTerminal); - const isCurrent = i === currentIndex && !isTerminal; - return ( -
- {i > 0 && ( -
- )} -
- - - {step} - {isDone && i === steps.length - 1 && ' \u2713'} +
+
+
+

+ Execution Flow +

+
+ + {humanizeStopReason(activeAutopilotContext.event) ?? humanizeText(activeAutopilotContext.event)} + + {elapsedLabel && ( + + {elapsedLabel} elapsed -
+ )}
- ); - })} - {elapsedLabel && {elapsedLabel}} +
+ {activeAutopilotProgress && ( +

+ {activeAutopilotProgressIsTerminalStop + ? activeOutcome?.label ?? 'Stopped' + : `${activeAutopilotProgress.pct}%`} +

+ )} +
+ + {currentIndex >= 0 && ( +
+ {steps.map((step, i) => { + const isDone = i < currentIndex || (i === currentIndex && isTerminal); + const isCurrent = i === currentIndex && !isTerminal; + return ( +
+ {i > 0 && ( +
+ )} +
+ + + {step} + {isDone && i === steps.length - 1 && ' \u2713'} + +
+
+ ); + })} +
+ )} + + {activeAutopilotProgress && ( +
+
+
+
+

+ {activeAutopilotProgressIsTerminalStop + ? activeOutcome?.summary ?? activeAutopilotProgress.label + : activeAutopilotProgress.label} +

+
+ )}
); })()} - {/* Progress bar — inline, no card wrapper */} - {activeAutopilotProgress && ( -
-
-

- {activeAutopilotProgressIsTerminalStop ? 'Terminal state' : 'Progress'} -

-

- {activeAutopilotProgressIsTerminalStop - ? activeOutcome?.label ?? 'Stopped' - : `${activeAutopilotProgress.pct}%`} -

-
-
-
-
-

- {activeAutopilotProgress.label} -

-
- )} - {/* People — simplified single-line attribution */}

@@ -6446,6 +6967,16 @@ export const ActivityTimeline = memo(function ActivityTimeline({

)} + {/* Milestone scope breakdown */} + {activeMilestoneBreakdown.length > 0 && ( +
+

+ Milestones +

+ +
+ )} + {/* Current step — simple key-value */} {(activeExecutionBreakdown?.taskTitle || activeExecutionBreakdown?.milestoneTitle || diff --git a/dashboard/src/components/mission-control/InProgressPanel.tsx b/dashboard/src/components/mission-control/InProgressPanel.tsx index c335f114..87adf4b5 100644 --- a/dashboard/src/components/mission-control/InProgressPanel.tsx +++ b/dashboard/src/components/mission-control/InProgressPanel.tsx @@ -206,6 +206,8 @@ export function selectInProgressRows({ const runningSliceRows: InProgressRow[] = []; for (const slice of sliceRuns) { + const sliceKind = (slice.sliceKind ?? '').trim().toLowerCase(); + if (sliceKind && sliceKind !== 'work_slice') continue; if (!SLICE_RUNNING_STATUSES.has(slice.status)) continue; const runId = (slice.runId ?? slice.sliceRunId ?? '').trim(); if (!runId) continue; @@ -213,6 +215,8 @@ export function selectInProgressRows({ const workstreamIds = normalizeLineageIds(slice.workstreamIds, slice.workstreamId); const primaryInitiativeId = initiativeIds[0] ?? null; const primaryWorkstreamId = workstreamIds[0] ?? null; + // In-progress should represent dispatchable IWMT slices only. + if (!primaryInitiativeId || !primaryWorkstreamId) continue; const scopeKeys: string[] = []; for (const iId of initiativeIds) { for (const wId of workstreamIds) { diff --git a/dashboard/src/components/mission-control/NextUpPanel.tsx b/dashboard/src/components/mission-control/NextUpPanel.tsx index 172f37d5..130367b5 100644 --- a/dashboard/src/components/mission-control/NextUpPanel.tsx +++ b/dashboard/src/components/mission-control/NextUpPanel.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion, Reorder, useDragControls, useReducedMotion } f import { useEffect, useMemo, useRef, useState } from 'react'; import { formatRelativeTime } from '@/lib/time'; import { cn } from '@/lib/utils'; -import { colors, missionControlMotion } from '@/lib/tokens'; +import { colors, missionControlMotion, stateTones } from '@/lib/tokens'; import { AgentAvatar } from '@/components/agents/AgentAvatar'; import { PremiumCard } from '@/components/shared/PremiumCard'; import { EntityIcon } from '@/components/shared/EntityIcon'; @@ -15,6 +15,7 @@ import { QueueState, queueTone, queueLabel, queueStateRank, queueHighlight, queu import { useNextUpQueue, type NextUpQueueItem, type UseNextUpQueueResult, type ZoomLevel, type InitiativeGroupItem, type MilestoneGroupItem } from '@/hooks/useNextUpQueue'; import { useNextUpQueueActions } from '@/hooks/useNextUpQueueActions'; import { EmptyState } from '@/components/shared/EmptyState'; +import { SegmentedProgressBar } from '@/components/shared/ScopeProgressCard'; import type { NextUpQueueBulkAction } from '@/types'; type UseNextUpQueueActionsResult = ReturnType; @@ -1235,7 +1236,7 @@ export function NextUpPanel({ ? 'Needs attention' : queueDisplayMode === QueueState.RUNNING ? 'Running now' - : 'Queue'} + : 'Next Up'}

{selectedCount > 0 ? ( @@ -1635,6 +1636,28 @@ export function NextUpPanel({ {runnerSourceBadge ? (

Runner {runnerSourceBadge}

) : null} + {/* Milestone progress strip */} + {item.milestoneBreakdown && item.milestoneBreakdown.length > 0 && ( +
+ +

+ {item.milestoneBreakdown.length} milestone{item.milestoneBreakdown.length !== 1 ? 's' : ''} + {' · '} + {item.milestoneBreakdown.reduce((s, m) => s + m.doneTasks, 0)}/ + {item.milestoneBreakdown.reduce((s, m) => s + m.totalTasks, 0)} tasks done +

+
+ )} + {/* Completed counts strip */} + {item.queueState === QueueState.COMPLETED && item.milestoneBreakdown && item.milestoneBreakdown.length > 0 && ( +
+ ✓ {item.milestoneBreakdown.length} milestones + ✓ {item.milestoneBreakdown.reduce((s, m) => s + m.totalTasks, 0)} tasks +
+ )} {/* Scoring tier + estimate */}
{(() => { @@ -1671,7 +1694,9 @@ export function NextUpPanel({ className="h-full rounded-full" style={{ width: `${Math.max(item.queueState === QueueState.COMPLETED ? 100 : item.queueState === QueueState.RUNNING ? 50 : 15, 4)}%`, - background: `linear-gradient(90deg, ${colors.lime}, ${colors.teal})`, + background: item.queueState === QueueState.COMPLETED + ? colors.teal + : `linear-gradient(90deg, ${colors.lime}, ${colors.teal})`, opacity: 0.6, }} /> @@ -2120,6 +2145,24 @@ function NextUpReorderRow({ )}
+ {/* Milestone breakdown (expanded card) */} + {item.milestoneBreakdown && item.milestoneBreakdown.length > 0 && ( +
+ +
+ {item.milestoneBreakdown.slice(0, 4).map((ms) => ( + + {ms.doneTasks === ms.totalTasks && ms.totalTasks > 0 ? '✓' : '○'} {ms.title} + {ms.doneTasks}/{ms.totalTasks} + + ))} + {item.milestoneBreakdown.length > 4 && ( + +{item.milestoneBreakdown.length - 4} + )} +
+
+ )} + {blockReason && (
Blocked: {blockReason} diff --git a/dashboard/src/components/mission-control/SliceDetailModal.tsx b/dashboard/src/components/mission-control/SliceDetailModal.tsx index 5bffcb51..3b31c23e 100644 --- a/dashboard/src/components/mission-control/SliceDetailModal.tsx +++ b/dashboard/src/components/mission-control/SliceDetailModal.tsx @@ -6,7 +6,7 @@ import { AgentAvatar } from '@/components/agents/AgentAvatar'; import { EntityIcon } from '@/components/shared/EntityIcon'; import { Pill } from '@/components/shared/Pill'; import { EntityCommentsPanel } from '@/components/comments/EntityCommentsPanel'; -import { ScopeProgressCard, buildScopeFromSliceRun } from '@/components/shared/ScopeProgressCard'; +import { ScopeProgressCard, buildScopeFromSliceRun, buildScopeFromMilestoneBreakdown, ScopeGroupedView } from '@/components/shared/ScopeProgressCard'; import { ArtifactGallery } from './ArtifactGallery'; import { MetricRow } from '@/components/shared/MetricRow'; import { formatRelativeTime } from '@/lib/time'; @@ -201,6 +201,7 @@ function extractData(target: SliceDetailTarget) { ? Math.max(0, Math.floor(item.sliceTaskCount)) : item.sliceTaskIds?.length ?? null, autoContinue: item.autoContinue, + milestoneBreakdown: item.milestoneBreakdown ?? null, sliceRun: linkedSliceRun, sessionId: null as string | null, runId: linkedSliceRun?.runId ?? null, @@ -233,6 +234,7 @@ function extractData(target: SliceDetailTarget) { ) : sliceRun?.taskIds?.length ?? null, autoContinue: null as NextUpQueueItem['autoContinue'] | null, + milestoneBreakdown: null as NextUpQueueItem['milestoneBreakdown'] | null, sliceRun: sliceRun, sessionId: row.session?.id ?? null, runId: row.runId, @@ -270,6 +272,7 @@ function extractData(target: SliceDetailTarget) { ) : sliceRun.taskIds?.length ?? null, autoContinue: null as NextUpQueueItem['autoContinue'] | null, + milestoneBreakdown: null as NextUpQueueItem['milestoneBreakdown'] | null, sliceRun: sliceRun, sessionId: null as string | null, runId: sliceRun.runId, @@ -466,6 +469,23 @@ export function SliceDetailModal({ const hasTerminal = terminalTarget !== null; const scopeNodes = useMemo(() => { + if (sr?.scopeProgress) { + return buildScopeFromSliceRun({ + initiativeId: d.initiativeId, + initiativeTitle: d.initiativeTitle, + workstreamId: d.workstreamId, + workstreamTitle: d.workstreamTitle, + taskIds: sr?.taskIds, + milestoneIds: sr?.milestoneIds, + scopeProgress: sr.scopeProgress, + status: sr?.status ?? d.queueState, + agentName: d.agentName, + agentId: d.agentId, + }); + } + if (d.milestoneBreakdown && d.milestoneBreakdown.length > 0) { + return buildScopeFromMilestoneBreakdown(d.milestoneBreakdown); + } return buildScopeFromSliceRun({ initiativeId: d.initiativeId, initiativeTitle: d.initiativeTitle, @@ -473,12 +493,12 @@ export function SliceDetailModal({ workstreamTitle: d.workstreamTitle, taskIds: sr?.taskIds, milestoneIds: sr?.milestoneIds, - scopeProgress: sr?.scopeProgress ?? null, + scopeProgress: null, status: sr?.status ?? d.queueState, agentName: d.agentName, agentId: d.agentId, }); - }, [d.initiativeId, d.initiativeTitle, d.workstreamId, d.workstreamTitle, sr, d.queueState, d.agentName, d.agentId]); + }, [d.initiativeId, d.initiativeTitle, d.workstreamId, d.workstreamTitle, sr, d.queueState, d.agentName, d.agentId, d.milestoneBreakdown]); const isNeedsReview = target.source === 'needs_input' && sr?.status === 'needs_review'; @@ -1004,10 +1024,7 @@ export function SliceDetailModal({ className="space-y-2" >

{workSnapshotHeading(d.queueState, isNeedsReview)}

- +
{d.nextTaskPriority !== null && priorityLabel(d.nextTaskPriority) && ( ({ + id: ms.id, + label: ms.title, + type: 'milestone' as const, + status: + ms.doneTasks === ms.totalTasks && ms.totalTasks > 0 + ? 'done' + : ms.tasks.some((t) => t.status === 'active' || t.status === 'running' || t.status === 'in_progress') + ? 'active' + : 'pending', + progress: { done: ms.doneTasks, total: ms.totalTasks }, + children: ms.tasks.map((t) => ({ + id: t.id, + label: t.title, + type: 'task' as const, + status: normalizeTaskStatus(t.status), + })), + })); +} + +export function groupScopeByState(nodes: ScopeNode[]): { + inProgress: ScopeNode[]; + completed: ScopeNode[]; + upcoming: ScopeNode[]; + blocked: ScopeNode[]; +} { + const inProgress: ScopeNode[] = []; + const completed: ScopeNode[] = []; + const upcoming: ScopeNode[] = []; + const blocked: ScopeNode[] = []; + for (const node of nodes) { + if (node.status === 'done') completed.push(node); + else if (node.status === 'active') inProgress.push(node); + else if (node.status === 'blocked' || node.status === 'failed') blocked.push(node); + else upcoming.push(node); + } + return { inProgress, completed, upcoming, blocked }; +} + +// --------------------------------------------------------------------------- +// Segmented progress bar (compact cards) +// --------------------------------------------------------------------------- + +export const SegmentedProgressBar = memo(function SegmentedProgressBar({ + milestones, + height = 3, +}: { + milestones: MilestoneBreakdownEntry[]; + height?: number; +}) { + const total = milestones.reduce((s, m) => s + m.totalTasks, 0); + if (total === 0) return null; + return ( +
+ {milestones.map((ms) => { + const widthPct = (ms.totalTasks / total) * 100; + const fillPct = ms.totalTasks > 0 ? (ms.doneTasks / ms.totalTasks) * 100 : 0; + const allDone = ms.doneTasks === ms.totalTasks && ms.totalTasks > 0; + return ( +
+
+
+ ); + })} +
+ ); +}); + +// --------------------------------------------------------------------------- +// Scope grouped view (modals) +// --------------------------------------------------------------------------- + +const SCOPE_SECTION_TONES: Record = { + active: stateTones.active, + done: stateTones.done, + blocked: stateTones.blocked, + planned: stateTones.planned, +}; + +function ScopeSection({ + label, + count, + tone, + nodes, + defaultOpen, + compact, +}: { + label: string; + count: number; + tone: 'active' | 'done' | 'blocked' | 'planned'; + nodes: ScopeNode[]; + defaultOpen: boolean; + compact?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const toneValues = SCOPE_SECTION_TONES[tone] ?? stateTones.planned; + return ( +
+ + + {open && ( + +
+ +
+
+ )} +
+
+ ); +} + +export const ScopeGroupedView = memo(function ScopeGroupedView({ + nodes, + compact = false, +}: { + nodes: ScopeNode[]; + compact?: boolean; +}) { + const groups = groupScopeByState(nodes); + return ( +
+ {groups.blocked.length > 0 && ( + + )} + {groups.inProgress.length > 0 && ( + + )} + {groups.completed.length > 0 && ( + + )} + {groups.upcoming.length > 0 && ( + + )} +
+ ); +}); + // --------------------------------------------------------------------------- // Status visuals // --------------------------------------------------------------------------- diff --git a/dashboard/src/hooks/useLiveData.ts b/dashboard/src/hooks/useLiveData.ts index 227b8531..256c8e25 100644 --- a/dashboard/src/hooks/useLiveData.ts +++ b/dashboard/src/hooks/useLiveData.ts @@ -1346,6 +1346,14 @@ function normalizeSliceRuns(input: SliceRunProjection[] | null | undefined): Sli }; for (const item of input) { if (!item || typeof item !== 'object') continue; + const sliceKind = + (typeof (item as { sliceKind?: unknown }).sliceKind === 'string' + ? (item as { sliceKind?: string }).sliceKind + : typeof (item as { slice_kind?: unknown }).slice_kind === 'string' + ? (item as { slice_kind?: string }).slice_kind + : null); + const normalizedSliceKind = sliceKind?.trim().toLowerCase() ?? null; + if (normalizedSliceKind && normalizedSliceKind !== 'work_slice') continue; const sliceRunId = (typeof item.sliceRunId === 'string' && item.sliceRunId.trim().length > 0 ? item.sliceRunId.trim() @@ -1372,6 +1380,7 @@ function normalizeSliceRuns(input: SliceRunProjection[] | null | undefined): Sli ...item, id: sliceRunId, sliceRunId, + sliceKind: normalizedSliceKind === 'work_slice' ? 'work_slice' : undefined, initiativeId, initiativeIds, workstreamId, @@ -1524,6 +1533,7 @@ function sliceRunsFromWorkSliceProjections( mapped.push({ id: projection.sliceRunId, sliceRunId: projection.sliceRunId, + sliceKind: 'work_slice', runId: projection.runId, initiativeId, initiativeIds: projection.lineage.initiativeIds, @@ -2159,8 +2169,10 @@ export function useLiveData(options: UseLiveDataOptions = {}) { return; } const message = err instanceof Error ? err.message : 'Unknown error'; - const isAuthBlocked = - Boolean(err && typeof err === 'object' && 'code' in err && (err as any).code === 'ORGX_AUTH'); + const isAuthBlocked = (() => { + if (err == null || typeof err !== 'object' || !('code' in err)) return false; + return (err as { code?: unknown }).code === 'ORGX_AUTH'; + })(); if (isAuthBlocked) { authBlockedRef.current = true; diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 75762865..74650530 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -231,6 +231,7 @@ export interface SliceRunDecisionOption { export interface SliceRunProjection { id: string; sliceRunId: string; + sliceKind?: SliceKind; runId: string | null; initiativeId: string | null; initiativeIds?: string[]; @@ -891,6 +892,20 @@ export interface MissionControlSlicesResponse { pagination?: MissionControlSlicesPagination; } +export interface MilestoneBreakdownTask { + id: string; + title: string; + status: string; +} + +export interface MilestoneBreakdownEntry { + id: string; + title: string; + tasks: MilestoneBreakdownTask[]; + totalTasks: number; + doneTasks: number; +} + export type NextUpRunnerSource = 'assigned' | 'inferred' | 'fallback'; export type NextUpQueueState = 'queued' | 'running' | 'blocked' | 'idle' | 'completed'; export type NextUpPlaybackState = @@ -949,6 +964,7 @@ export interface NextUpQueueItem { sliceTaskIds?: string[]; sliceTaskCount?: number | null; sliceMilestoneId?: string | null; + milestoneBreakdown?: MilestoneBreakdownEntry[]; executionPolicy?: NextUpExecutionPolicy | null; autoContinue: { status: AutoContinueStatus; diff --git a/scripts/agent-browser-live-ui-p0-audit.mjs b/scripts/agent-browser-live-ui-p0-audit.mjs index 22758408..1b5675eb 100755 --- a/scripts/agent-browser-live-ui-p0-audit.mjs +++ b/scripts/agent-browser-live-ui-p0-audit.mjs @@ -128,8 +128,12 @@ function collectInitiativeIds(rows, keys) { function hasPositiveDecisionBadge(values) { for (const entry of values) { - const match = String(entry || "").match(/decisions?\s*(\d+)/i); - if (match && Number(match[1]) > 0) return true; + const text = String(entry || ""); + const wordFirst = text.match(/decisions?\s*[:\-]?\s*(\d+)/i); + if (wordFirst && Number(wordFirst[1]) > 0) return true; + + const numberFirst = text.match(/(\d+)\s*\+?\s*decisions?/i); + if (numberFirst && Number(numberFirst[1]) > 0) return true; } return false; } @@ -283,8 +287,21 @@ async function run() { await nextUpTab.click({ timeout: 4_000 }).catch(() => {}); await page.waitForTimeout(400); } - const nextUpActionButtons = page.getByRole("button", { name: /^(Start|Pause|Resume)$/i }); - report.ui.nextUpActionVisible = (await nextUpActionButtons.count().catch(() => 0)) > 0; + const nextUpActionButtons = page.getByRole("button", { name: /^(Start|Pause|Resume|Running)$/i }); + report.ui.nextUpActionVisible = false; + if (report.api.nextUpItems > 0) { + const startedAtMs = Date.now(); + while (Date.now() - startedAtMs < 8_000) { + const count = await nextUpActionButtons.count().catch(() => 0); + if (count > 0) { + report.ui.nextUpActionVisible = true; + break; + } + await page.waitForTimeout(350); + } + } else { + report.ui.nextUpActionVisible = (await nextUpActionButtons.count().catch(() => 0)) > 0; + } if (!report.ui.nextUpActionVisible && report.api.nextUpItems > 0) { const missionControlTab = page.getByRole("button", { name: /Mission Control/i }).first(); if (await missionControlTab.count()) { diff --git a/src/http/routes/mission-control-read.ts b/src/http/routes/mission-control-read.ts index 5f161aff..59e5eee5 100644 --- a/src/http/routes/mission-control-read.ts +++ b/src/http/routes/mission-control-read.ts @@ -52,6 +52,7 @@ type NextUpQueueItem = { sliceTaskIds?: string[]; sliceTaskCount?: number | null; sliceMilestoneId?: string | null; + milestoneBreakdown?: MilestoneBreakdownEntry[]; isPinned?: boolean; pinnedRank?: number | null; compositeScore?: number; @@ -116,9 +117,19 @@ type GraphTaskNode = { updatedAt: string | null; }; +type MilestoneBreakdownTask = { id: string; title: string; status: string }; +type MilestoneBreakdownEntry = { + id: string; + title: string; + tasks: MilestoneBreakdownTask[]; + totalTasks: number; + doneTasks: number; +}; + type InitiativeGraphIndex = { tasksById: Map; milestoneTitleById: Map; + milestonesByWorkstream: Map>; }; type RegisterMissionControlReadRoutesDeps = { @@ -1053,6 +1064,7 @@ async function loadInitiativeGraphIndex( const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; const tasksById = new Map(); const milestoneTitleById = new Map(); + const milestoneWorkstream = new Map(); for (const nodeEntry of nodes) { const node = asRecord(nodeEntry); @@ -1062,6 +1074,8 @@ async function loadInitiativeGraphIndex( if (!id || !type) continue; if (type === "milestone") { milestoneTitleById.set(id, asString(node.title) ?? id); + const wsId = asString(node.workstreamId) ?? asString(node.parentId); + if (wsId) milestoneWorkstream.set(id, wsId); continue; } if (type !== "task") continue; @@ -1077,12 +1091,65 @@ async function loadInitiativeGraphIndex( }); } + // Build milestonesByWorkstream from milestones + their child tasks + const milestonesByWorkstream = new Map>(); + for (const [msId, wsId] of milestoneWorkstream) { + const taskIds: string[] = []; + for (const task of tasksById.values()) { + if (task.milestoneId === msId) taskIds.push(task.id); + } + const entry = { id: msId, title: milestoneTitleById.get(msId) ?? msId, taskIds }; + const existing = milestonesByWorkstream.get(wsId) ?? []; + existing.push(entry); + milestonesByWorkstream.set(wsId, existing); + } + return { tasksById, milestoneTitleById, + milestonesByWorkstream, }; } +async function enrichWithMilestoneBreakdown( + items: NextUpQueueItem[], + deps: RegisterMissionControlReadRoutesDeps +): Promise { + if (items.length === 0) return items; + const graphByInitiative = new Map(); + const uniqueInitiatives = dedupeStrings(items.map((i) => i.initiativeId)); + for (const id of uniqueInitiatives) { + try { + graphByInitiative.set(id, await loadInitiativeGraphIndex(deps, id)); + } catch { + // graph unavailable — skip enrichment for this initiative + } + } + if (graphByInitiative.size === 0) return items; + for (const item of items) { + const graph = graphByInitiative.get(item.initiativeId); + if (!graph) continue; + const milestones = graph.milestonesByWorkstream.get(item.workstreamId) ?? []; + if (milestones.length === 0) continue; + item.milestoneBreakdown = milestones.map((ms) => { + const tasks: MilestoneBreakdownTask[] = ms.taskIds.map((tid) => { + const task = graph.tasksById.get(tid); + return { id: tid, title: task?.title ?? "Untitled", status: task?.status ?? "pending" }; + }); + return { + id: ms.id, + title: ms.title, + tasks, + totalTasks: tasks.length, + doneTasks: tasks.filter( + (t) => t.status === "done" || t.status === "completed" + ).length, + }; + }); + } + return items; +} + export function registerMissionControlReadRoutes( router: Router, TReq, TRes>, deps: RegisterMissionControlReadRoutesDeps @@ -1497,11 +1564,14 @@ export function registerMissionControlReadRoutes( if (isCanonicalAllScopeMismatch(canonicalSlicesRecord, useAllScope)) { throw new Error("canonical slices all-workspaces scope mismatch"); } - const bridgedItems = applyQueueNoiseControls( - mapCanonicalSlicesToQueueItems(canonicalSlicesRecord.items).filter((item) => - includeCompleted ? true : item.queueState !== "completed" + const bridgedItems = await enrichWithMilestoneBreakdown( + applyQueueNoiseControls( + mapCanonicalSlicesToQueueItems(canonicalSlicesRecord.items).filter((item) => + includeCompleted ? true : item.queueState !== "completed" + ), + { noiseThreshold, dedupWindowMs } ), - { noiseThreshold, dedupWindowMs } + deps ); if (bridgedItems.length > 0) { const paged = applySliceSearchAndPagination({ @@ -1546,11 +1616,14 @@ export function registerMissionControlReadRoutes( initiativeId, projectId, }); - const items = applyQueueNoiseControls( - normalizeQueueItems(queue.items ?? []).filter((item) => - includeCompleted ? true : item.queueState !== "completed" + const items = await enrichWithMilestoneBreakdown( + applyQueueNoiseControls( + normalizeQueueItems(queue.items ?? []).filter((item) => + includeCompleted ? true : item.queueState !== "completed" + ), + { noiseThreshold, dedupWindowMs } ), - { noiseThreshold, dedupWindowMs } + deps ); const paged = applySliceSearchAndPagination({ items, @@ -1588,11 +1661,14 @@ export function registerMissionControlReadRoutes( initiativeId, projectId, }); - const items = applyQueueNoiseControls( - normalizeQueueItems(queue.items ?? []).filter((item) => - includeCompleted ? true : item.queueState !== "completed" + const items = await enrichWithMilestoneBreakdown( + applyQueueNoiseControls( + normalizeQueueItems(queue.items ?? []).filter((item) => + includeCompleted ? true : item.queueState !== "completed" + ), + { noiseThreshold, dedupWindowMs } ), - { noiseThreshold, dedupWindowMs } + deps ); const paged = applySliceSearchAndPagination({ items,