diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 2ef051d8..39218530 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -848,6 +848,17 @@ export default function App() { }, }, ); + } catch (rawError) { + // The monitor's onError handles errors surfaced through polling; this + // catch covers the awaited create*Attempt POST that runs before the + // monitor (backend down / 4xx). Mirror onError's shouldIgnoreRun guard so + // an ignored/cancelled run isn't re-surfaced here. + if (shouldIgnoreRun(currentRunIdRef.current)) return; + const err = rawError instanceof Error ? rawError : new Error(String(rawError)); + if (!(err instanceof BackendClientError && err.code === 'USER_CANCELLED')) { + setError(err.message); + setErrorRetryable(err instanceof BackendClientError && err.details?.retryable === true); + } } finally { setIsAnalyzing(false); abortControllerRef.current = null; @@ -917,6 +928,17 @@ export default function App() { }, }, ); + } catch (rawError) { + // The monitor's onError handles errors surfaced through polling; this + // catch covers the awaited create*Attempt POST that runs before the + // monitor (backend down / 4xx). Mirror onError's shouldIgnoreRun guard so + // an ignored/cancelled run isn't re-surfaced here. + if (shouldIgnoreRun(currentRunIdRef.current)) return; + const err = rawError instanceof Error ? rawError : new Error(String(rawError)); + if (!(err instanceof BackendClientError && err.code === 'USER_CANCELLED')) { + setError(err.message); + setErrorRetryable(err instanceof BackendClientError && err.details?.retryable === true); + } } finally { setIsAnalyzing(false); abortControllerRef.current = null; diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 4fb91e40..0e3340f6 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -27,6 +27,7 @@ import { Phase2ConsistencyReport } from './Phase2ConsistencyReport'; import { isBrowserLoudnessConfigEnabled } from '../config'; import { BrowserLoudnessPanel } from './BrowserLoudnessPanel'; import { MeasurementDashboard } from './MeasurementDashboard'; +import { ReconstructionContractPanel } from './ReconstructionContractPanel'; import { PatchSmithPanel } from './PatchSmithPanel'; import { SamplePlayback } from './SamplePlayback'; import { SessionMusicianPanel } from './SessionMusicianPanel'; @@ -1815,6 +1816,8 @@ export function AnalysisResults({ )} + {isPhase2V2 && } + {/* Audit Finding #1: measurements section moved to the end of the scroll. Wrapped in a single anchorable
so the StickyNav can target it with one pill ("Measurements") instead of nine pills. The internal diff --git a/apps/ui/src/components/ReconstructionContractPanel.tsx b/apps/ui/src/components/ReconstructionContractPanel.tsx new file mode 100644 index 00000000..efcd5538 --- /dev/null +++ b/apps/ui/src/components/ReconstructionContractPanel.tsx @@ -0,0 +1,84 @@ +/** + * Reconstruction Contract — the verbatim recommendations.v1 envelope (ADR 0003). + * + * The recommendation cards pair each device with its contract entry inline + * (ContractValidatedBadge / ContractEntriesBlock). This panel is the + * complementary single machine-view: the flat, schema-validated, citation-gated + * set exactly as it is exported to the asa-ableton .als generator and the + * phase2-export envelope. Every row carries ≥1 cited measurement by + * construction. Renders nothing when the envelope is absent (older runs, + * stem_summary) or empty. + */ +import React, { useState } from 'react'; + +import type { RecommendationsContract, RecommendationContractEntry } from '../types'; +import { formatContractValue, formatContractRange } from '../services/recommendationsContract'; +import { CollapsibleCard, DataTable, type DataTableColumn } from './ui'; + +interface ReconstructionContractPanelProps { + contract?: RecommendationsContract | null; + className?: string; +} + +const COLUMNS: DataTableColumn[] = [ + { key: 'device', label: 'Device' }, + { key: 'parameter', label: 'Parameter' }, + { + key: 'value', + label: 'Value', + render: (entry) => ( + + {formatContractValue(entry)} + + ), + }, + { + key: 'range', + label: 'Working range', + render: (entry) => { + const range = formatContractRange(entry); + return ( + {range ?? '—'} + ); + }, + }, + { + key: 'cited_measurements', + label: 'Cited measurements', + render: (entry) => ( +
+ {entry.cited_measurements.map((path, index) => ( + + {path} + + ))} +
+ ), + }, +]; + +export function ReconstructionContractPanel({ + contract, + className, +}: ReconstructionContractPanelProps) { + const [open, setOpen] = useState(false); + const entries = contract?.recommendations ?? []; + if (entries.length === 0) { + return null; + } + + return ( + setOpen((prev) => !prev)} + tone="success" + className={className} + eyebrow="recommendations.v1 · exported to .als" + title={`Reconstruction Contract · ${entries.length} validated recommendation${ + entries.length === 1 ? '' : 's' + }`} + > + + + ); +}