feat(reports): show chapter progress while a report is being written (BUG-601)

The worker already persists a phase ladder and durable per-section rows;
the waiting screen parsed the progress fields and rendered none of them.
Chapter progress now drives the screen: one cell per chapter rather than
a percentage bar, since the job percent jumps 0->30 and 90->100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
This commit is contained in:
Jesse_Chen
2026-09-09 03:30:15 +00:00
co-authored by Claude Fable 5
parent 301eae4b76
commit 848e39e61f
9 changed files with 684 additions and 18 deletions
@@ -25,6 +25,7 @@ import {
type ReportDocument,
type ReportDocumentV2,
} from "@/lib/personal-report-contract";
import { reportThemeLabel } from "@/lib/personal-report-progress";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
multi_system_consensus: "多系统一致",
@@ -61,15 +62,6 @@ const DEPTH_LABELS: Record<ReportDocumentV2["depth"], string> = {
research: "研究",
};
const THEME_LABELS: Record<string, string> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
const ACTION_PRIORITY_LABELS: Record<ReportDocumentV2["actionNotes"][number]["priority"], string> = {
now: "现在",
@@ -152,7 +144,7 @@ function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[])
}
function themeLabel(theme: string): string {
return THEME_LABELS[theme] ?? theme;
return reportThemeLabel(theme);
}
function chartEvidenceRefs(chart: ReportChart): readonly string[] {
@@ -19,10 +19,18 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { ReportActions } from "./report-actions";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import {
PERSONAL_REPORT_GENERATING_COPY,
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "@/lib/personal-report-longform-copy";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
reportProgressSignature,
type ReportSectionProgress,
type ReportSectionProgressState,
} from "@/lib/personal-report-progress";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -30,7 +38,12 @@ export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| {
phase: "generating";
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
@@ -52,6 +65,31 @@ export interface ReportEnvelopeView {
completedAt: string | null;
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
"done",
"failed",
"writing",
"waiting",
];
/**
* Chapter rows are only trusted in the shape the route promises; anything else
* is dropped rather than rendered as a mystery row.
*/
function readSectionProgress(value: unknown): readonly ReportSectionProgress[] | undefined {
if (!Array.isArray(value)) return undefined;
const parsed = value.flatMap((entry) => {
if (!isRecord(entry)) return [];
const { id, state } = entry;
if (typeof id !== "string" || id.length === 0) return [];
if (typeof state !== "string") return [];
const known = SECTION_PROGRESS_STATES.find((candidate) => candidate === state);
return known ? [{ id, state: known }] : [];
});
return parsed.length > 0 ? parsed : undefined;
}
/**
@@ -96,10 +134,12 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
case "generating": {
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
const progressPhase = typeof view.progressPhase === "string" ? view.progressPhase : undefined;
const sections = readSectionProgress(view.sections);
return {
phase: "generating",
...(progressPercent === undefined ? {} : { progressPercent }),
...(progressPhase === undefined ? {} : { progressPhase }),
...(sections === undefined ? {} : { sections }),
};
}
case "failed": {
@@ -152,6 +192,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
const [waitedMs, setWaitedMs] = useState(0);
// When observable progress last changed. Only a real change to the phase,
// percent or chapter states moves this, so a chapter being retried keeps
// the old mark and eventually trips the slow notice.
const [progressMark, setProgressMark] = useState<{ signature: string; at: number } | null>(null);
const cancelledRef = useRef(false);
const load = useCallback(() => {
@@ -170,9 +214,18 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "generating") {
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
const signature = reportProgressSignature({
phase: next.progressPhase,
percent: next.progressPercent,
sections: next.sections,
});
setProgressMark((mark) => (
mark && mark.signature === signature ? mark : { signature, at: Date.now() }
));
} else {
setWaitStartedAt(null);
setWaitedMs(0);
setProgressMark(null);
}
setState(next);
})
@@ -191,6 +244,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const keepWaiting = useCallback(() => {
setWaitStartedAt(Date.now());
setWaitedMs(0);
setProgressMark(null);
setState({ phase: "generating" });
void load();
}, [load]);
@@ -229,12 +283,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
});
if (state.phase === "loading" || state.phase === "generating") {
// The stall clock rides the existing one-second tick: waitStartedAt +
// waitedMs is "now" without a second timer or an effect.
const stalled = state.phase === "generating"
&& waitStartedAt !== null
&& progressMark !== null
&& (waitStartedAt + waitedMs) - progressMark.at >= REPORT_PROGRESS_STALL_MS;
const progress = state.phase === "generating"
? describeReportProgress({
phase: state.progressPhase,
sections: state.sections,
stalled,
})
: null;
const writing = progress?.stage === "writing";
return (
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">
{generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
</p>
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
{generating && (
<>
<p> {formatWaitedDuration(waitedMs)}</p>
@@ -0,0 +1,35 @@
/**
* Chapter progress for a report that is still being written.
*
* One cell per chapter — the bar is the chapter list in compact form, so the
* two can never disagree. Deliberately not a percentage bar: the job's percent
* jumps 0→30 and 90→100 in single steps, and only the middle band tracks real
* work, so a linear percentage would read as motion the backend has not made.
*
* Nothing animates on a timer. A chapter under retry looks stalled here
* because it is stalled.
*/
"use client";
import type { ReportProgressView } from "@/lib/personal-report-progress";
export function PersonalReportProgressPanel({ progress }: { progress: ReportProgressView }) {
return (
<div className="report-progress">
<ol className="report-progress-track" aria-hidden="true">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state} />
))}
</ol>
<ul className="report-progress-chapters">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state}>
<span className="report-progress-chapter-name">{chapter.label}</span>
<span className="report-progress-chapter-status">{chapter.statusText}</span>
</li>
))}
</ul>
</div>
);
}