From 258846170ac0480568a1e22746f01858b0f6c515 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Thu, 5 Mar 2026 07:58:32 -0600 Subject: [PATCH] feat: add milestone breakdown hierarchy to Next Up queue, cards, and modals Enrich Next Up queue items with milestoneBreakdown data from the initiative graph, and render it consistently across cards (segmented progress bar), modals (status-grouped collapsible sections), and completed items (teal counts strip). Rename "Queue" label to "Next Up". Co-Authored-By: Claude Opus 4.6 --- dashboard/src/App.tsx | 114 ++- .../components/activity/ActivityTimeline.tsx | 753 +++++++++++++++--- .../mission-control/InProgressPanel.tsx | 4 + .../mission-control/NextUpPanel.tsx | 49 +- .../mission-control/SliceDetailModal.tsx | 31 +- .../components/shared/ScopeProgressCard.tsx | 195 ++++- dashboard/src/hooks/useLiveData.ts | 16 +- dashboard/src/types.ts | 16 + scripts/agent-browser-live-ui-p0-audit.mjs | 25 +- src/http/routes/mission-control-read.ts | 100 ++- 10 files changed, 1160 insertions(+), 143 deletions(-) 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,