From 9ac6a42ac07ebdebfd8c74c6890048265586072a Mon Sep 17 00:00:00 2001 From: ANTFOR7717 Date: Fri, 24 Jul 2026 07:43:08 -0400 Subject: [PATCH 1/3] feat: show pipeline sub-stage on estimate status bar Reads Mastra's own workflow-run storage to derive which of the four pipeline stages (extraction, classification, enrichment, presentation) a processing estimate is on, and surfaces it in the status bar's label/tooltip in place of the generic "Processing" state. --- .../dashboard/estimate/[id]/intake/page.tsx | 9 +++ .../estimate-extraction-pipeline/progress.ts | 57 +++++++++++++++++++ .../components/estimate-intake-view.tsx | 4 ++ .../components/estimate-status-bar.tsx | 44 ++++++++++++-- .../components/estimates-list-view.tsx | 18 +++++- .../components/recent-estimates-widget.tsx | 18 +++++- 6 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 src/features/estimate-extraction-pipeline/progress.ts diff --git a/src/app/dashboard/estimate/[id]/intake/page.tsx b/src/app/dashboard/estimate/[id]/intake/page.tsx index 8f31089..0752b4c 100644 --- a/src/app/dashboard/estimate/[id]/intake/page.tsx +++ b/src/app/dashboard/estimate/[id]/intake/page.tsx @@ -25,6 +25,14 @@ export default async function EstimateIntakePage({ const identity = intakeIdentitySchema.safeParse(row.intakeExtraction); + let pipelineSubStage = null; + if (row.status === "processing") { + const { getEstimatePipelineSubStage } = await import( + "@/features/estimate-extraction-pipeline/progress" + ); + pipelineSubStage = await getEstimatePipelineSubStage(row.workflowRunId); + } + return (
); diff --git a/src/features/estimate-extraction-pipeline/progress.ts b/src/features/estimate-extraction-pipeline/progress.ts new file mode 100644 index 0000000..4e79a8e --- /dev/null +++ b/src/features/estimate-extraction-pipeline/progress.ts @@ -0,0 +1,57 @@ +import { mastra } from './index'; + +/** + * Read-only progress signal, decoupled from the app's DB: nothing here + * writes anything, anywhere. It reads Mastra's own workflow-run storage + * (the same snapshot data `restart()`/`getWorkflowRunById()` already rely + * on — persisted per step transition, not only at suspend) and derives + * which of the four non-suspending pipeline stages is furthest along. + * `triggerSummarizeEstimate()`/`resumeSummarizeEstimate()` are untouched; + * this is purely an additional read path a caller can consult, and any + * failure here (storage unreachable, run not found, unexpected shape) + * resolves to `null` rather than throwing, so a fault here can't break + * whatever is rendering the estimate's status. + */ + +export type PipelineSubStageId = 'extraction' | 'classification' | 'enrichment' | 'presentation'; + +export type PipelineSubStage = { + stageId: PipelineSubStageId; + status: 'running' | 'success'; +}; + +/** + * Step ids as declared in `pipeline.ts`'s composition root. Each of these + * four stages is composed as a workflow-as-step, so it appears as one + * atomic entry in the parent run's `steps` record — not a flood of every + * leaf agent call inside it. + */ +const SUB_STAGE_STEP_IDS: { stageId: PipelineSubStageId; stepId: string }[] = [ + { stageId: 'extraction', stepId: 'Extraction' }, + { stageId: 'classification', stepId: 'Classify Findings' }, + { stageId: 'enrichment', stepId: 'enrichment-fanout' }, + { stageId: 'presentation', stepId: 'Presentation' }, +]; + +export async function getEstimatePipelineSubStage( + workflowRunId: string | null | undefined, +): Promise { + if (!workflowRunId) return null; + + try { + const workflow = mastra.getWorkflow('summarize-estimate'); + const state = await workflow.getWorkflowRunById(workflowRunId); + if (!state?.steps) return null; + + let current: PipelineSubStage | null = null; + for (const { stageId, stepId } of SUB_STAGE_STEP_IDS) { + const entry = state.steps[stepId]; + const result = Array.isArray(entry) ? entry[entry.length - 1] : entry; + if (!result) continue; + current = { stageId, status: result.status === 'success' ? 'success' : 'running' }; + } + return current; + } catch { + return null; + } +} diff --git a/src/features/estimate/components/estimate-intake-view.tsx b/src/features/estimate/components/estimate-intake-view.tsx index 79440ef..93e438a 100644 --- a/src/features/estimate/components/estimate-intake-view.tsx +++ b/src/features/estimate/components/estimate-intake-view.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { FileSearch } from "lucide-react"; import type { IntakeIdentity } from "@/features/estimate-extraction-pipeline/intake"; +import type { PipelineSubStage } from "@/features/estimate-extraction-pipeline/progress"; import type { EstimateStatus } from "../db/schema"; import { EstimateConfirmationView } from "./estimate-confirmation-view"; import { EstimateRetryButton } from "./estimate-retry-button"; @@ -19,6 +20,7 @@ interface EstimateIntakeViewProps { identity: IntakeIdentity | null; phase: "identity" | "timeframe"; errorMessage: string | null; + pipelineSubStage?: PipelineSubStage | null; } export function EstimateIntakeView({ @@ -27,6 +29,7 @@ export function EstimateIntakeView({ identity, phase, errorMessage, + pipelineSubStage = null, }: EstimateIntakeViewProps) { const router = useRouter(); const isProcessing = status === "uploaded" || status === "processing"; @@ -45,6 +48,7 @@ export function EstimateIntakeView({ identityConfirmed={Boolean(identity && phase === "timeframe")} timeframeSelected={Boolean(identity && phase === "timeframe" && status === "processing")} errorMessage={errorMessage} + pipelineSubStage={pipelineSubStage} /> ); diff --git a/src/features/estimate/components/estimate-status-bar.tsx b/src/features/estimate/components/estimate-status-bar.tsx index 803dfce..30e8901 100644 --- a/src/features/estimate/components/estimate-status-bar.tsx +++ b/src/features/estimate/components/estimate-status-bar.tsx @@ -1,6 +1,7 @@ "use client"; import type { EstimateStatus } from "@/features/estimate/db/schema"; +import type { PipelineSubStage } from "@/features/estimate-extraction-pipeline/progress"; import { Tooltip, TooltipContent, @@ -10,6 +11,25 @@ import { cn } from "@/lib/utils"; export type StageId = "uploaded" | "processing" | "analyzed" | "priced" | "delivered"; +const PIPELINE_SUBSTAGE_TEXT: Record = { + extraction: { + label: "Extracting findings", + description: "Reading every page of the report for billable items.", + }, + classification: { + label: "Classifying items", + description: "Sorting extracted findings into trades and cost types.", + }, + enrichment: { + label: "Pricing materials & labor", + description: "Applying local market pricing to each line item.", + }, + presentation: { + label: "Finalizing estimate", + description: "Assembling the final estimate for review.", + }, +}; + const STAGES: { id: StageId; label: string; description: string }[] = [ { id: "uploaded", label: "Uploaded", description: "Report received and queued." }, { @@ -71,6 +91,15 @@ interface EstimateStatusBarProps { identityConfirmed?: boolean; timeframeSelected?: boolean; errorMessage?: string | null; + /** + * Optional, best-effort sub-stage read from the pipeline's own run + * state (see `estimate-extraction-pipeline/progress.ts`). Only ever + * consulted while `phase === "processing"`; absent or `null` (feature + * unavailable, read failed, or run hasn't reached a sub-stage yet) + * falls straight back to the generic "Processing" label — never a + * required prop for correct rendering. + */ + pipelineSubStage?: PipelineSubStage | null; className?: string; } @@ -79,12 +108,16 @@ export function EstimateStatusBar({ identityConfirmed = false, timeframeSelected = false, errorMessage, + pipelineSubStage = null, className, }: EstimateStatusBarProps) { const phase = getPhase({ status, identityConfirmed, timeframeSelected }); const litIndex = litUpTo(phase); const isFailed = phase === "failed"; const isActive = phase === "processing" || phase === "identity" || phase === "timeframe"; + const subStageText = + phase === "processing" && pipelineSubStage ? PIPELINE_SUBSTAGE_TEXT[pipelineSubStage.stageId] : null; + const displayLabel = subStageText?.label ?? phaseLabel(phase); return (
{STAGES.map((stage, i) => { const lit = !isFailed && i <= litIndex; const active = isActive && i === litIndex; const failed = isFailed && i === 1; + const isProcessingStage = stage.id === "processing"; const tooltipText = failed ? errorMessage ?? "Processing failed." - : stage.description; + : isProcessingStage && subStageText + ? subStageText.description + : stage.description; return ( @@ -119,7 +155,7 @@ export function EstimateStatusBar({
- {stage.label} + {isProcessingStage && subStageText ? subStageText.label : stage.label} {active && " · in progress"} {failed && " · failed"} {lit && !active && !failed && " ✓"} @@ -133,7 +169,7 @@ export function EstimateStatusBar({ })}
- {phaseLabel(phase)} + {displayLabel}
); diff --git a/src/features/estimate/components/estimates-list-view.tsx b/src/features/estimate/components/estimates-list-view.tsx index 5a9b6a1..0fa086e 100644 --- a/src/features/estimate/components/estimates-list-view.tsx +++ b/src/features/estimate/components/estimates-list-view.tsx @@ -2,6 +2,7 @@ import { db } from "@/db"; import { estimateRequestTable } from "../db/schema"; import { eq, desc } from "drizzle-orm"; import { authServerProvider } from "@/auth/server-provider"; +import type { PipelineSubStage } from "@/features/estimate-extraction-pipeline/progress"; import { headers } from "next/headers"; import Link from "next/link"; import { FileText } from "lucide-react"; @@ -22,6 +23,20 @@ export async function EstimatesListView() { .where(eq(estimateRequestTable.userId, session.user.id)) .orderBy(desc(estimateRequestTable.createdAt)); + let pipelineSubStages: (PipelineSubStage | null)[] = estimates.map(() => null); + if (estimates.some((upload) => upload.status === "processing")) { + const { getEstimatePipelineSubStage } = await import( + "@/features/estimate-extraction-pipeline/progress" + ); + pipelineSubStages = await Promise.all( + estimates.map((upload) => + upload.status === "processing" + ? getEstimatePipelineSubStage(upload.workflowRunId) + : Promise.resolve(null), + ), + ); + } + return (
@@ -51,7 +66,7 @@ export async function EstimatesListView() {
- {estimates.map((upload) => { + {estimates.map((upload, index) => { const leftBlock = (
@@ -93,6 +108,7 @@ export async function EstimatesListView() { identityConfirmed={Boolean(upload.intakeConfirmedAt)} timeframeSelected={Boolean(upload.timeframe)} errorMessage={upload.errorMessage} + pipelineSubStage={pipelineSubStages[index]} /> {upload.status === "failed" && ( diff --git a/src/features/estimate/components/recent-estimates-widget.tsx b/src/features/estimate/components/recent-estimates-widget.tsx index 6ac9a2d..0f760ba 100644 --- a/src/features/estimate/components/recent-estimates-widget.tsx +++ b/src/features/estimate/components/recent-estimates-widget.tsx @@ -2,6 +2,7 @@ import { db } from "@/db"; import { estimateRequestTable } from "../db/schema"; import { eq, desc } from "drizzle-orm"; import { authServerProvider } from "@/auth/server-provider"; +import type { PipelineSubStage } from "@/features/estimate-extraction-pipeline/progress"; import { headers } from "next/headers"; import { FileText, ArrowRight } from "lucide-react"; import { Button } from "@/design-systems/shadcn/components/button"; @@ -25,6 +26,20 @@ export async function RecentEstimatesWidget() { .orderBy(desc(estimateRequestTable.createdAt)) .limit(5); + let pipelineSubStages: (PipelineSubStage | null)[] = recentUploads.map(() => null); + if (recentUploads.some((upload) => upload.status === "processing")) { + const { getEstimatePipelineSubStage } = await import( + "@/features/estimate-extraction-pipeline/progress" + ); + pipelineSubStages = await Promise.all( + recentUploads.map((upload) => + upload.status === "processing" + ? getEstimatePipelineSubStage(upload.workflowRunId) + : Promise.resolve(null), + ), + ); + } + if (recentUploads.length === 0) { return (
@@ -50,7 +65,7 @@ export async function RecentEstimatesWidget() { return (
- {recentUploads.map((upload) => { + {recentUploads.map((upload, index) => { const content = ( <>
@@ -74,6 +89,7 @@ export async function RecentEstimatesWidget() { identityConfirmed={Boolean(upload.intakeConfirmedAt)} timeframeSelected={Boolean(upload.timeframe)} errorMessage={upload.errorMessage} + pipelineSubStage={pipelineSubStages[index]} /> {upload.status === "failed" && ( From dda5b80902dade878cdeb8508715a7abcc528dd2 Mon Sep 17 00:00:00 2001 From: ANTFOR7717 Date: Fri, 24 Jul 2026 09:04:18 -0400 Subject: [PATCH 2/3] fix: read pipeline sub-stage from its own storage row, add per-step bar getEstimatePipelineSubStage() was reading state.steps[stepId] off the parent "Generate Estimate" run, but each stage is composed as a workflow-as-step and Mastra persists it as its own storage row (same runId, different workflow_name) rather than folding it into the parent's steps record. Confirmed against mastra_workflow_snapshot directly: the parent run's own steps never gained entries for any of the four stages, so this always returned null and the status bar just showed the generic pulsing "Processing" state for the whole duration. Now reads each stage's snapshot directly via workflowName. Also renders the four sub-stages as individual bar segments during processing instead of a single pulsing bar. --- .../estimate-extraction-pipeline/progress.ts | 61 +++++++++++-------- .../components/estimate-status-bar.tsx | 47 ++++++++++++++ 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/features/estimate-extraction-pipeline/progress.ts b/src/features/estimate-extraction-pipeline/progress.ts index 4e79a8e..0869677 100644 --- a/src/features/estimate-extraction-pipeline/progress.ts +++ b/src/features/estimate-extraction-pipeline/progress.ts @@ -3,14 +3,26 @@ import { mastra } from './index'; /** * Read-only progress signal, decoupled from the app's DB: nothing here * writes anything, anywhere. It reads Mastra's own workflow-run storage - * (the same snapshot data `restart()`/`getWorkflowRunById()` already rely - * on — persisted per step transition, not only at suspend) and derives - * which of the four non-suspending pipeline stages is furthest along. - * `triggerSummarizeEstimate()`/`resumeSummarizeEstimate()` are untouched; - * this is purely an additional read path a caller can consult, and any - * failure here (storage unreachable, run not found, unexpected shape) - * resolves to `null` rather than throwing, so a fault here can't break - * whatever is rendering the estimate's status. + * and derives which of the four non-suspending pipeline stages is + * furthest along. `triggerSummarizeEstimate()`/`resumeSummarizeEstimate()` + * are untouched; this is purely an additional read path a caller can + * consult, and any failure here (storage unreachable, run not found, + * unexpected shape) resolves to `null` rather than throwing, so a fault + * here can't break whatever is rendering the estimate's status. + * + * Each of the four stages is composed in `pipeline.ts` as a + * workflow-as-step (`.then(extractionFanoutWorkflow)` etc.), and Mastra + * persists a nested workflow-as-step's run as its OWN storage row — + * same `runId` as the parent `Generate Estimate` run, but keyed under + * its own `workflow_name` (its own `id`, e.g. `'Extraction'`) — rather + * than folding it into the parent run's own `steps` record. Confirmed + * directly against `mastra_workflow_snapshot`: a mid-flight run had + * `Extraction`/`Classify Findings` at `workflow_name='Extraction'` / + * `'Classify Findings'` with `status: 'success'`, `enrichment-fanout` at + * `'running'`, and no row yet for `'Presentation'`, while the PARENT + * run's own snapshot never gained entries for any of the four. So this + * reads each stage's snapshot directly via `workflowName`, not through + * `workflow.getWorkflowRunById()`'s parent-run `steps` record. */ export type PipelineSubStageId = 'extraction' | 'classification' | 'enrichment' | 'presentation'; @@ -21,16 +33,16 @@ export type PipelineSubStage = { }; /** - * Step ids as declared in `pipeline.ts`'s composition root. Each of these - * four stages is composed as a workflow-as-step, so it appears as one - * atomic entry in the parent run's `steps` record — not a flood of every - * leaf agent call inside it. + * Workflow ids as declared by each stage's own `createWorkflow({ id: ... })` + * in `pipeline.ts`'s composition root (extraction/steps.ts, + * classification/workflow.ts, enrichment/workflow.ts, + * presentation/workflow.ts). */ -const SUB_STAGE_STEP_IDS: { stageId: PipelineSubStageId; stepId: string }[] = [ - { stageId: 'extraction', stepId: 'Extraction' }, - { stageId: 'classification', stepId: 'Classify Findings' }, - { stageId: 'enrichment', stepId: 'enrichment-fanout' }, - { stageId: 'presentation', stepId: 'Presentation' }, +const SUB_STAGE_WORKFLOW_NAMES: { stageId: PipelineSubStageId; workflowName: string }[] = [ + { stageId: 'extraction', workflowName: 'Extraction' }, + { stageId: 'classification', workflowName: 'Classify Findings' }, + { stageId: 'enrichment', workflowName: 'enrichment-fanout' }, + { stageId: 'presentation', workflowName: 'Presentation' }, ]; export async function getEstimatePipelineSubStage( @@ -39,16 +51,15 @@ export async function getEstimatePipelineSubStage( if (!workflowRunId) return null; try { - const workflow = mastra.getWorkflow('summarize-estimate'); - const state = await workflow.getWorkflowRunById(workflowRunId); - if (!state?.steps) return null; + const storage = mastra.getStorage(); + const workflowStore = await storage?.getStore('workflows'); + if (!workflowStore) return null; let current: PipelineSubStage | null = null; - for (const { stageId, stepId } of SUB_STAGE_STEP_IDS) { - const entry = state.steps[stepId]; - const result = Array.isArray(entry) ? entry[entry.length - 1] : entry; - if (!result) continue; - current = { stageId, status: result.status === 'success' ? 'success' : 'running' }; + for (const { stageId, workflowName } of SUB_STAGE_WORKFLOW_NAMES) { + const snapshot = await workflowStore.loadWorkflowSnapshot({ runId: workflowRunId, workflowName }); + if (!snapshot) continue; + current = { stageId, status: snapshot.status === 'success' ? 'success' : 'running' }; } return current; } catch { diff --git a/src/features/estimate/components/estimate-status-bar.tsx b/src/features/estimate/components/estimate-status-bar.tsx index 30e8901..d5c0bd1 100644 --- a/src/features/estimate/components/estimate-status-bar.tsx +++ b/src/features/estimate/components/estimate-status-bar.tsx @@ -30,6 +30,13 @@ const PIPELINE_SUBSTAGE_TEXT: Record + {SUB_STAGE_ORDER.map((subStageId, subIndex) => { + const subLit = subIndex < subStageIndex; + const subActive = subIndex === subStageIndex; + const subText = PIPELINE_SUBSTAGE_TEXT[subStageId]; + + return ( + + +
+ + +
+ {subText.label} + {subActive && " · in progress"} + {subLit && !subActive && " ✓"} +
+
+ {subText.description} +
+
+ + ); + })} +
+ ); + } + const tooltipText = failed ? errorMessage ?? "Processing failed." : isProcessingStage && subStageText From 3fddf63f9ad2284cb4e74c225bb9fd0a12b4066b Mon Sep 17 00:00:00 2001 From: ANTFOR7717 Date: Fri, 24 Jul 2026 09:19:08 -0400 Subject: [PATCH 3/3] refactor: drive the 5 status-bar segments directly with pipeline data Replaces the nested 4-dot sub-stage bar with a direct remap of the existing 5 segments (Uploaded/Extracted/Analyzing/Pricing/Delivered) onto the real pipeline sub-stages, instead of bolting new UI onto the single "Processing" slot. --- .../components/estimate-status-bar.tsx | 210 +++++++----------- 1 file changed, 78 insertions(+), 132 deletions(-) diff --git a/src/features/estimate/components/estimate-status-bar.tsx b/src/features/estimate/components/estimate-status-bar.tsx index d5c0bd1..988acff 100644 --- a/src/features/estimate/components/estimate-status-bar.tsx +++ b/src/features/estimate/components/estimate-status-bar.tsx @@ -1,7 +1,7 @@ "use client"; import type { EstimateStatus } from "@/features/estimate/db/schema"; -import type { PipelineSubStage } from "@/features/estimate-extraction-pipeline/progress"; +import type { PipelineSubStage, PipelineSubStageId } from "@/features/estimate-extraction-pipeline/progress"; import { Tooltip, TooltipContent, @@ -9,90 +9,87 @@ import { } from "@/design-systems/shadcn/components/tooltip"; import { cn } from "@/lib/utils"; -export type StageId = "uploaded" | "processing" | "analyzed" | "priced" | "delivered"; - -const PIPELINE_SUBSTAGE_TEXT: Record = { - extraction: { - label: "Extracting findings", - description: "Reading every page of the report for billable items.", - }, - classification: { - label: "Classifying items", - description: "Sorting extracted findings into trades and cost types.", - }, - enrichment: { - label: "Pricing materials & labor", - description: "Applying local market pricing to each line item.", - }, - presentation: { - label: "Finalizing estimate", - description: "Assembling the final estimate for review.", - }, -}; - -const SUB_STAGE_ORDER: PipelineSubStage["stageId"][] = [ - "extraction", - "classification", - "enrichment", - "presentation", -]; +export type StageId = "uploaded" | "extraction" | "classification" | "enrichment" | "delivered"; const STAGES: { id: StageId; label: string; description: string }[] = [ { id: "uploaded", label: "Uploaded", description: "Report received and queued." }, - { - id: "processing", - label: "Processing", - description: "AI is reading the inspection report and extracting billable items.", - }, - { id: "analyzed", label: "Analyzed", description: "Billable items extracted from the report." }, - { id: "priced", label: "Priced", description: "Local market pricing has been applied to each item." }, + { id: "extraction", label: "Extracted", description: "Reading every page of the report for billable items." }, + { id: "classification", label: "Analyzing", description: "Sorting extracted findings into trades and cost types." }, + { id: "enrichment", label: "Pricing", description: "Applying local market pricing to each line item." }, { id: "delivered", label: "Delivered", description: "Final estimate is ready to view and send." }, ]; -type EstimatePhase = - | "uploaded" - | "processing" - | "identity" - | "timeframe" - | "completed" - | "failed"; +/** Maps each real pipeline sub-stage directly onto its bar slot. */ +const SUB_STAGE_INDEX: Record = { + extraction: 1, + classification: 2, + enrichment: 3, + presentation: 4, +}; + +interface Progress { + /** Last fully-completed index, -1 if none. */ + litUpTo: number; + /** Currently in-progress index, -1 if none. */ + activeIndex: number; + /** -1 if not failed. */ + failedIndex: number; +} -function getPhase({ +function computeProgress({ + status, + pipelineSubStage, +}: { + status: EstimateStatus; + pipelineSubStage: PipelineSubStage | null; +}): Progress { + if (status === "completed") { + return { litUpTo: STAGES.length - 1, activeIndex: -1, failedIndex: -1 }; + } + + if (status === "failed") { + const knownIndex = pipelineSubStage ? SUB_STAGE_INDEX[pipelineSubStage.stageId] : 0; + const failedIndex = + pipelineSubStage?.status === "success" ? Math.min(knownIndex + 1, STAGES.length - 1) : knownIndex; + return { litUpTo: failedIndex - 1, activeIndex: -1, failedIndex }; + } + + if (pipelineSubStage) { + const index = SUB_STAGE_INDEX[pipelineSubStage.stageId]; + if (pipelineSubStage.status === "success") { + return { litUpTo: index, activeIndex: -1, failedIndex: -1 }; + } + return { litUpTo: index - 1, activeIndex: index, failedIndex: -1 }; + } + + // No sub-stage data yet: still uploaded, awaiting HITL confirmation, or + // in the initial parse/identity-extraction stretch before extraction + // starts. Nothing beyond "uploaded" is knowable yet. + return { litUpTo: 0, activeIndex: status === "processing" ? 0 : -1, failedIndex: -1 }; +} + +function captionLabel({ status, identityConfirmed, timeframeSelected, + pipelineSubStage, }: { status: EstimateStatus; identityConfirmed: boolean; timeframeSelected: boolean; -}): EstimatePhase { - if (status === "failed") return "failed"; - if (status === "completed") return "completed"; + pipelineSubStage: PipelineSubStage | null; +}): string { + if (status === "failed") return "Failed"; + if (status === "completed") return "Delivered"; if (status === "awaiting_confirmation") { - if (!identityConfirmed) return "identity"; - if (!timeframeSelected) return "timeframe"; + if (!identityConfirmed) return "Confirm identity"; + if (!timeframeSelected) return "Select timeframe"; } - if (status === "processing" || timeframeSelected) return "processing"; - return "uploaded"; -} - -function phaseLabel(phase: EstimatePhase): string { - if (phase === "identity") return "Confirm identity"; - if (phase === "timeframe") return "Select timeframe"; - if (phase === "completed") return "Completed"; - if (phase === "failed") return "Failed"; - if (phase === "processing") return "Processing"; + if (pipelineSubStage) return STAGES[SUB_STAGE_INDEX[pipelineSubStage.stageId]].label; + if (status === "processing") return "Processing"; return "Uploaded"; } -function litUpTo(phase: EstimatePhase): number { - if (phase === "processing") return 1; - if (phase === "identity") return 2; - if (phase === "timeframe") return 3; - if (phase === "completed") return STAGES.length - 1; - return 0; -} - interface EstimateStatusBarProps { status: EstimateStatus; identityConfirmed?: boolean; @@ -100,11 +97,10 @@ interface EstimateStatusBarProps { errorMessage?: string | null; /** * Optional, best-effort sub-stage read from the pipeline's own run - * state (see `estimate-extraction-pipeline/progress.ts`). Only ever - * consulted while `phase === "processing"`; absent or `null` (feature - * unavailable, read failed, or run hasn't reached a sub-stage yet) - * falls straight back to the generic "Processing" label — never a - * required prop for correct rendering. + * state (see `estimate-extraction-pipeline/progress.ts`). Absent or + * `null` (feature unavailable, read failed, or run hasn't reached a + * sub-stage yet) just leaves the bar at "Uploaded" until it resolves + * — never a required prop for correct rendering. */ pipelineSubStage?: PipelineSubStage | null; className?: string; @@ -118,15 +114,9 @@ export function EstimateStatusBar({ pipelineSubStage = null, className, }: EstimateStatusBarProps) { - const phase = getPhase({ status, identityConfirmed, timeframeSelected }); - const litIndex = litUpTo(phase); - const isFailed = phase === "failed"; - const isActive = phase === "processing" || phase === "identity" || phase === "timeframe"; - const subStageText = - phase === "processing" && pipelineSubStage ? PIPELINE_SUBSTAGE_TEXT[pipelineSubStage.stageId] : null; - const displayLabel = subStageText?.label ?? phaseLabel(phase); - const subStageIndex = pipelineSubStage ? SUB_STAGE_ORDER.indexOf(pipelineSubStage.stageId) : -1; - const showSubStageBars = phase === "processing" && pipelineSubStage !== null; + const { litUpTo, activeIndex, failedIndex } = computeProgress({ status, pipelineSubStage }); + const isFailed = failedIndex !== -1; + const label = captionLabel({ status, identityConfirmed, timeframeSelected, pipelineSubStage }); return (
{STAGES.map((stage, i) => { - const lit = !isFailed && i <= litIndex; - const active = isActive && i === litIndex; - const failed = isFailed && i === 1; - const isProcessingStage = stage.id === "processing"; - - if (isProcessingStage && showSubStageBars) { - return ( -
- {SUB_STAGE_ORDER.map((subStageId, subIndex) => { - const subLit = subIndex < subStageIndex; - const subActive = subIndex === subStageIndex; - const subText = PIPELINE_SUBSTAGE_TEXT[subStageId]; - - return ( - - -
- - -
- {subText.label} - {subActive && " · in progress"} - {subLit && !subActive && " ✓"} -
-
- {subText.description} -
-
- - ); - })} -
- ); - } - - const tooltipText = failed - ? errorMessage ?? "Processing failed." - : isProcessingStage && subStageText - ? subStageText.description - : stage.description; + const lit = !isFailed && i <= litUpTo; + const active = !isFailed && i === activeIndex; + const failed = isFailed && i === failedIndex; return ( @@ -202,13 +148,13 @@ export function EstimateStatusBar({
- {isProcessingStage && subStageText ? subStageText.label : stage.label} + {stage.label} {active && " · in progress"} {failed && " · failed"} {lit && !active && !failed && " ✓"}
- {tooltipText} + {failed ? errorMessage ?? "Processing failed." : stage.description}
@@ -216,7 +162,7 @@ export function EstimateStatusBar({ })}
- {displayLabel} + {label}
);