/** * PBL v2 — Milestone reflection + handover (combined) card. * * Per PR 6 D7-B: we merge what the refactor/openmaic-pbl repo had as * TWO separate cards (MilestoneNarrativeCard + HandoverCard) into ONE * card so the learner doesn't have to scroll past evaluation prose * to reach the "继续到下一阶段" button. * * Card sections (top → bottom): * 1. Header strip: 阶段完成 ribbon + StarRating (plain, no /5) * 2. Narrative paragraph (the LLM's prose, with the JSON tail * stripped via stripEvaluationTail) * 3. "你学到了" bullets (from evaluation.strengths a.k.a. `learned`) * 4. "这阶段你..." prose line (from evaluation.improvements[0] * a.k.a. `performance`) * 5. Handover footer: next-milestone label + "继续到下一阶段" CTA. * The button is the ONLY way to cross into the next stage — * while `pendingHandover.consumed === false` the next milestone * stays LOCKED in the store (gate enforced by continueAfter * Handover in operations/kernel/progress.ts). * * Visual scope: * - full-width (not a bubble); breaks out of the chat's max-width * cell so the card feels like a "moment", not a message. * - gentle gradient background to distinguish from regular bubbles. * * Final-milestone case: when there is no `pendingHandover` (we just * completed the LAST milestone of the project) the footer renders a * "项目即将完成" label instead of the Continue button. The * CompletionCtaCard renders separately AFTER the final evaluation * finishes streaming. */ 'use client'; import { ArrowRight, CheckCircle2, Flag } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { sanitizeMilestoneEvaluationFeedback, stripEvaluationTail, } from '@/lib/pbl/v2/operations/runtime/eval-tail-parser'; import { MarkdownText } from '../markdown-text'; import { StarRating } from './star-rating'; import type { PBLEvaluation, PBLHandover } from '@/lib/pbl/v2/types'; import { trimmedPBLText } from '@/lib/pbl/v2/readers'; interface Props { evaluation: PBLEvaluation; /** When present, render the Continue CTA. When absent (final * milestone), render the project-completion label instead. */ handover?: PBLHandover; /** Called when the learner clicks "继续到下一阶段". The wiring * (continueAfterHandover + open-task setup) lives in PR 6.6. */ onContinue?: () => void; className?: string; } export function MilestoneCard({ evaluation, handover, onContinue, className }: Props) { const { t } = useI18n(); if (evaluation.kind !== 'milestone') return null; const learned = evaluation.strengths ?? []; const performance = (evaluation.improvements ?? [])[0] ?? ''; const stars = typeof evaluation.stars === 'number' ? evaluation.stars : null; const rawNarrative = stripEvaluationTail( sanitizeMilestoneEvaluationFeedback(trimmedPBLText(evaluation.feedback)), ); const narrative = handover ? rawNarrative : stripFinalMilestoneContinueGuidance(rawNarrative); const ctaState = milestoneHandoverCtaState(handover); const consumed = ctaState === 'consumed'; return (
{/* Header */}
{t('pbl.v2.milestoneCard.title')}
{stars !== null && (
)}
{/* Narrative paragraph */} {narrative && (
)} {/* Learned bullets */} {learned.length > 0 && (
{t('pbl.v2.milestoneCard.youLearned')}
    {learned.map((s, i) => (
  • {s}
  • ))}
)} {/* Performance prose */} {performance && (
{performance}
)} {/* Handover footer */}
{handover ? (
{t('pbl.v2.milestoneCard.nextStage')} {handover.nextMilestoneTitle} {!consumed && (
{t('pbl.v2.milestoneCard.continueHint')}
)}
) : (
{t('pbl.v2.milestoneCard.projectAlmostDone')}
)}
); } export function milestoneHandoverCtaState(handover?: PBLHandover): 'hidden' | 'ready' | 'consumed' { if (!handover) return 'hidden'; return handover.consumed ? 'consumed' : 'ready'; } export function stripFinalMilestoneContinueGuidance(text: string): string { return text .replace( /(?:[。!?!?]\s*)?[^。!?!?]*(?:点击|按|选择)\s*(?:右侧|下方|这个)?\s*(?:Continue|继续)(?:\s*按钮)?[^。!?!?]*(?:[。!?!?]|$)/giu, (match) => { const trimmed = match.trimStart(); return trimmed.startsWith('。') || trimmed.startsWith('!') || trimmed.startsWith('?') ? trimmed.charAt(0) : ''; }, ) .replace(/\s{2,}/g, ' ') .trim(); }