diff --git a/apps/ui/src/components/AnalysisStatusPanel.tsx b/apps/ui/src/components/AnalysisStatusPanel.tsx index 071b3c2b..2fba38f9 100644 --- a/apps/ui/src/components/AnalysisStatusPanel.tsx +++ b/apps/ui/src/components/AnalysisStatusPanel.tsx @@ -1,7 +1,21 @@ import React from 'react'; import { RotateCcw, Square } from 'lucide-react'; -import { AnalysisRunSnapshot, AnalysisStageError, AnalysisStageStatus, BackendAnalysisEstimate } from '../types'; +import { + AnalysisRunSnapshot, + AnalysisStageError, + AnalysisStageStatus, + BackendAnalysisEstimate, +} from '../types'; +import { + Button, + DeviceRack, + Panel, + SignalChain, + type SignalStage, + type SignalStageStatus, + type SignalTone, +} from './ui'; interface AnalysisStatusPanelProps { run: AnalysisRunSnapshot | null; @@ -26,7 +40,7 @@ function formatElapsed(ms: number): string { function formatEstimateRange(estimate: BackendAnalysisEstimate): string { const lo = Math.round(estimate.totalLowMs / 1000); const hi = Math.round(estimate.totalHighMs / 1000); - return `${lo}s-${hi}s`; + return `${lo}s–${hi}s`; } export type ProgressTone = 'running' | 'success' | 'failed'; @@ -70,36 +84,6 @@ const STAGE_LABELS: Record = { interpretation: 'INTERPRET', }; -/** - * Maps progress tone to the Tailwind background-color class for the progress - * bar fill. Used to surface failed/successful end-states visually, instead of - * leaving the bar accent-orange even when a stage has FAILED. Indeterminate - * fills use a lower-opacity variant so the pulsing partial bar reads as - * activity rather than solid colour. Audit N1 sibling. - */ -function progressFillClass(tone: ProgressTone, indeterminate: boolean): string { - if (tone === 'failed') return indeterminate ? 'bg-error/60' : 'bg-error'; - if (tone === 'success') return indeterminate ? 'bg-success/60' : 'bg-success'; - return indeterminate ? 'bg-accent/60' : 'bg-accent'; -} - -function statusDotClass(status: AnalysisStageStatus): string { - switch (status) { - case 'running': - case 'queued': - return 'bg-accent animate-pulse'; - case 'completed': - return 'bg-success'; - case 'failed': - case 'interrupted': - return 'bg-error'; - case 'not_requested': - return 'bg-text-secondary/30'; - default: - return 'bg-border'; - } -} - function getStageSnapshot(run: AnalysisRunSnapshot, stageKey: StageKey) { switch (stageKey) { case 'measurement': @@ -246,24 +230,45 @@ export function computeLiveProgress( }; } -function statusTextClass(status: AnalysisStageStatus): string { +/** + * Map a stage's internal status onto the visual SignalChain status. The + * SignalChain primitive owns the device-rack chrome for each stage, so we + * collapse the wider AnalysisStageStatus enum onto its smaller vocabulary. + * + * - `running` → active (LED pulses, cable animates) + * - `queued` → queued (waiting in line behind earlier stages) + * - `blocked` → queued (waiting on measurement to finish; same + * visual semantic as queued) + * - `ready` → idle (failed stage was reset and is now waiting + * for the user to click Retry — the Retry + * button in the action slot owns this state's + * CTA, so the device tile reads as idle + * rather than queued-for-auto-execution) + * - `completed` → success + * - `failed`/`interrupted` → error + * - `not_requested` → idle (stage was not requested for this run) + */ +export function toSignalStatus(status: AnalysisStageStatus): SignalStageStatus { switch (status) { case 'running': + return 'active'; case 'queued': - return 'text-accent'; + case 'blocked': + return 'queued'; case 'completed': - return 'text-success'; + return 'success'; case 'failed': case 'interrupted': - return 'text-error'; + return 'error'; + case 'ready': case 'not_requested': - return 'text-text-secondary/50'; + return 'idle'; default: - return 'text-text-secondary'; + return 'idle'; } } -function statusLabel(status: AnalysisStageStatus): string { +export function statusLabel(status: AnalysisStageStatus): string { switch (status) { case 'running': return 'RUNNING'; case 'queued': return 'QUEUED'; @@ -277,6 +282,21 @@ function statusLabel(status: AnalysisStageStatus): string { } } +/** + * Map the live ProgressState + isActive onto a DeviceRack status tone for + * the outer ANALYSIS RUN rack. Failure dominates (error tone) regardless of + * isActive; an explicit success tone overrides isActive; otherwise the rack + * lights up active while running and falls to idle when no stage is active. + */ +export function rackStatusFromProgress( + progress: ProgressState, + isActive: boolean, +): 'idle' | 'active' | 'success' | 'error' { + if (progress.tone === 'failed') return 'error'; + if (progress.tone === 'success') return 'success'; + return isActive ? 'active' : 'idle'; +} + export function AnalysisStatusPanel({ run, elapsedMs, @@ -315,156 +335,126 @@ export function AnalysisStatusPanel({ }, ]; + const signalStages: SignalStage[] = stages.map((stage) => { + const errorMarkedNonRetryable = stage.error?.retryable === false; + const isRetryable = + stage.onRetry && + (stage.status === 'failed' || stage.status === 'interrupted' || stage.status === 'ready') && + !errorMarkedNonRetryable; + // Audit N1: non-retryable failures (e.g. GEMINI_NOT_CONFIGURED) used to + // render FAILED with no actionable feedback. Surface the error code in + // the action slot so the user knows where to look; the full message + // lives in the title attribute. + const nonRetryableHint = + errorMarkedNonRetryable && stage.error?.code ? ( + + {stage.error.code} + + ) : null; + + const action = isRetryable ? ( + + ) : ( + nonRetryableHint + ); + + return { + key: stage.key, + name: STAGE_LABELS[stage.key], + status: toSignalStatus(stage.status), + statusLabel: statusLabel(stage.status), + action, + }; + }); + + const rackStatus = rackStatusFromProgress(progress, isActive); + const subtitle = run ? `· ${run.runId.slice(-8)}` : undefined; + const railTone: SignalTone = + progress.tone === 'success' ? 'success' : progress.tone === 'failed' ? 'idle' : 'active'; + + const railProgressLabel = progress.indeterminate + ? 'estimating' + : `${Math.round(progress.percent)}%`; + return ( -
- {/* Header row */} -
-
- Analysis Run - {run && ( - - {run.runId} - - )} -
-
-
-
- {formatElapsed(elapsedMs)} -
+ } + onClick={onStopAnalysis} + title="Stop analysis" + aria-label="Stop analysis" + > + Stop + + ) : undefined + } + signalIn={railTone} + signalOut={progress.tone === 'success' ? 'success' : 'idle'} + railContent={ + + + {formatElapsed(elapsedMs)} + {estimate && ( - - est {formatEstimateRange(estimate)} - + est {formatEstimateRange(estimate)} )} - {onStopAnalysis && isActive && ( - + {railProgressLabel} + + } + > +
+ {/* Audit Finding #6: primary readout. The stage diagnostic message + used to render at `text-[9px] text-secondary/50` below the percent + — sized as background fluff. During a 4–5 minute Phase 2 wait the + producer would tab away and miss any actual signal about what's + happening. Now it sits at the top of the device body as the visual + focus, with the active stage label as a mono eyebrow above it. The + SignalChain below becomes the secondary "which stage is which" + landmark. */} + + {progress.activeStageKey && ( +

+ {STAGE_LABELS[progress.activeStageKey]} + {progress.tone === 'failed' ? ' · failure' : null} +

)} -
-
- - {/* Audit Finding #6: primary readout. The stage diagnostic message used - to render at `text-[9px] text-secondary/50` below the percent — sized - as background fluff. During a 4–5 minute Phase 2 wait the producer - would tab away and miss any actual signal about what's happening. - Now it sits between the header and the stage chips as the visual - focus, with the active stage label as a mono eyebrow above it. The - chips below become the secondary "which stage is which" landmark. */} -
- {progress.activeStageKey && ( -

- {STAGE_LABELS[progress.activeStageKey]} - {progress.tone === 'failed' ? ' · failure' : null} +

+ {progress.message}

- )} -

- {progress.message} -

-
+ - {/* Stage pipeline */} -
- {stages.map((stage, i) => { - // A retry button only makes sense when (a) the parent provided a - // handler, (b) the stage is in a retryable state, and (c) the - // backend hasn't explicitly marked the error as non-retryable - // (e.g. GEMINI_NOT_CONFIGURED: clicking RETRY won't fix a missing - // env var). Audit N1 sibling: previously the button rendered for - // every failed stage regardless of error.retryable. - const errorMarkedNonRetryable = stage.error?.retryable === false; - const isRetryable = - stage.onRetry && - (stage.status === 'failed' || stage.status === 'interrupted' || stage.status === 'ready') && - !errorMarkedNonRetryable; - return ( -
-
- - {STAGE_LABELS[stage.key]} - -
-
-
- - {statusLabel(stage.status)} - - {isRetryable ? ( - - ) : errorMarkedNonRetryable && stage.error?.code ? ( - // Audit N1 sibling: a non-retryable failure (e.g. - // GEMINI_NOT_CONFIGURED) used to render FAILED with no - // actionable feedback. Surface the error code so the user - // knows where to look; full message goes in the tooltip. - - {stage.error.code} - - ) : null} -
-
- ); - })} -
- - {/* Progress bar */} -
-
- {progress.indeterminate ? ( -
- ) : ( -
= 95 && progress.tone === 'running' ? 'animate-pulse' : '' - }`} - style={{ width: `${progress.percent}%` }} - /> - )} -
-
- - {progress.indeterminate ? 'estimating' : `${Math.round(progress.percent)}%`} - -
- {/* Audit Finding #6: the duplicate small `progress.message` that used - to render here was removed — the primary readout above is the - single source for "what's happening". Keeping it here would have - been visual noise repeating the same sentence twice. */} +
-
+ ); } diff --git a/apps/ui/src/components/SamplePlayback.tsx b/apps/ui/src/components/SamplePlayback.tsx index ffe57133..a47bd671 100644 --- a/apps/ui/src/components/SamplePlayback.tsx +++ b/apps/ui/src/components/SamplePlayback.tsx @@ -7,6 +7,14 @@ import { generateSamples, } from '../services/sampleGenerationClient'; import { BackendClientError } from '../services/backendPhase1Client'; +import { + Button, + DeviceRack, + Panel, + Pill, + SectionHeader, +} from './ui'; +import type { Tone } from './ui'; interface SamplePlaybackProps { runId: string | null | undefined; @@ -31,6 +39,18 @@ const CATEGORY_LABELS: Record = { melody: 'Melody / lead phrase', }; +type RackStatus = 'idle' | 'active' | 'success' | 'warning' | 'error'; + +function rackStatusFor( + panelStatus: PanelStatus, + hasManifest: boolean, +): RackStatus { + if (panelStatus.kind === 'error') return 'error'; + if (panelStatus.kind === 'generating') return 'active'; + if (hasManifest) return 'success'; + return 'idle'; +} + /** * Audition panel that renders generated audio clips for the current run. * @@ -98,79 +118,84 @@ export function SamplePlayback({ if (!runId) return null; + const rackStatus = rackStatusFor(status, Boolean(manifest)); + return ( -
-
-
-

- Audition samples (Phase 3 — heuristic) -

-

- Short clips derived from Phase 1 measurements (and Phase 2 context when - available) so you can ear-check the measurement chain. These are not - Ableton-accurate reconstructions — follow Phase 2 in Live for the - production character. -

-
- {manifest && ( - - )} -
- - {!measurementCompleted && ( -

- Measurements still running — audition samples become available once Phase 1 - completes. + + ) + } + > +

+

+ Short clips derived from Phase 1 measurements (and Phase 2 context when + available) so you can ear-check the measurement chain. These are not + Ableton-accurate reconstructions — follow Phase 2 in Live for the + production character.

- )} - {measurementCompleted && !manifest && status.kind !== 'generating' && ( - - )} + {!measurementCompleted && ( +

+ Measurements still running — audition samples become available once + Phase 1 completes. +

+ )} - {status.kind === 'generating' && ( -

Rendering audition clips…

- )} + {measurementCompleted && !manifest && status.kind !== 'generating' && ( + + )} - {status.kind === 'error' && ( -

- {status.message} -

- )} + {status.kind === 'generating' && ( +

+ Rendering audition clips… +

+ )} - {manifest && groupedSamples.length > 0 && ( -
- - {groupedSamples.map(([category, samples]) => ( - - - - ))} -
- )} -
+ {status.kind === 'error' && ( +

+ {status.message} +

+ )} + + {manifest && groupedSamples.length > 0 && ( +
+ + {groupedSamples.map(([category, samples]) => ( + + + + ))} +
+ )} +
+ ); } @@ -184,13 +209,9 @@ function ManifestMeta({ manifest }: { manifest: SamplesManifest }) { ? 'PyTheory' : 'Pure-Python theory fallback'; return ( -
- - Music theory: {theoryLabel} - - - Synthesis: {synthesisLabel} - +
+ Music theory: {theoryLabel} + Synthesis: {synthesisLabel}
); } @@ -204,15 +225,18 @@ interface SampleGroupProps { function SampleGroup({ category, samples, runId, apiBaseUrl }: SampleGroupProps) { return ( -
-

- {CATEGORY_LABELS[category]} -

-
diff --git a/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts b/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts new file mode 100644 index 00000000..25e7f278 --- /dev/null +++ b/apps/ui/tests/services/analysisStatusPanelHelpers.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + rackStatusFromProgress, + statusLabel, + toSignalStatus, + type ProgressState, +} from '../../src/components/AnalysisStatusPanel'; +import type { AnalysisStageStatus } from '../../src/types'; + +describe('toSignalStatus', () => { + it.each<[AnalysisStageStatus, string]>([ + ['running', 'active'], + ['queued', 'queued'], + ['blocked', 'queued'], + ['completed', 'success'], + ['failed', 'error'], + ['interrupted', 'error'], + ['ready', 'idle'], + ['not_requested', 'idle'], + ])('maps %s → %s', (input, expected) => { + expect(toSignalStatus(input)).toBe(expected); + }); + + it('maps unknown statuses to idle', () => { + // Cast to bypass the AnalysisStageStatus union — exercising the default branch. + expect(toSignalStatus('mystery' as AnalysisStageStatus)).toBe('idle'); + }); + + it('treats ready as idle (not queued) so the Retry CTA owns the action', () => { + // ready means "failed stage was reset; user must click Retry". The action + // slot already carries the Retry button; the tile must read as idle so + // the chain doesn't look like it will auto-resume. + expect(toSignalStatus('ready')).toBe('idle'); + expect(toSignalStatus('ready')).not.toBe('queued'); + }); +}); + +describe('statusLabel', () => { + it.each<[AnalysisStageStatus, string]>([ + ['running', 'RUNNING'], + ['queued', 'QUEUED'], + ['completed', 'DONE'], + ['failed', 'FAILED'], + ['interrupted', 'STOPPED'], + ['not_requested', 'SKIP'], + ['blocked', 'WAIT'], + ['ready', 'READY'], + ])('maps %s → %s', (input, expected) => { + expect(statusLabel(input)).toBe(expected); + }); + + it('uppercases unknown statuses', () => { + expect(statusLabel('weird-state' as AnalysisStageStatus)).toBe('WEIRD-STATE'); + }); +}); + +describe('rackStatusFromProgress', () => { + function progress( + tone: ProgressState['tone'], + overrides: Partial = {}, + ): ProgressState { + return { + percent: 50, + indeterminate: false, + message: '', + tone, + activeStageKey: null, + ...overrides, + }; + } + + it('failure tone always wins over isActive', () => { + expect(rackStatusFromProgress(progress('failed'), true)).toBe('error'); + expect(rackStatusFromProgress(progress('failed'), false)).toBe('error'); + }); + + it('success tone always wins over isActive', () => { + expect(rackStatusFromProgress(progress('success'), true)).toBe('success'); + expect(rackStatusFromProgress(progress('success'), false)).toBe('success'); + }); + + it('running + active → active', () => { + expect(rackStatusFromProgress(progress('running'), true)).toBe('active'); + }); + + it('running + not active → idle', () => { + // Edge case: progress tone says running but isActive=false. The rack + // should fall back to idle rather than lying about activity. + expect(rackStatusFromProgress(progress('running'), false)).toBe('idle'); + }); +});