feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)

TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-07 12:58:58 +08:00
co-authored by Claude Opus 5.5
parent 328f919d5d
commit 6ad0bea320
51 changed files with 1487 additions and 243 deletions
+35 -2
View File
@@ -28,12 +28,20 @@ import {
noteUnsplitListRender,
} from "@/lib/home-streaming-render-probe";
import { memo, useEffect, useState, type MutableRefObject } from "react";
import {
BeforeBirthTimeChangeLabel,
RectificationOfferCard,
RectificationReturnDivider,
} from "@/components/rectification-in-chat-cards";
import { lastBirthTimeChangeIndex } from "@/lib/rectification-in-chat";
export type ChatTranscriptActions = Readonly<{
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
onCopy: (feedbackKey: string, text: string) => void;
onRegenerate: (renderKey: string) => void;
onFollowUp: (question: string) => void;
/** The offer card under an answer starts rectification for this consultation (BUG-1248). */
onStartRectification: (sourceSessionId: string) => void;
}>;
export type ChatTranscriptProps = Readonly<{
@@ -89,15 +97,18 @@ function MessageEntry({
productEntrypointsDisabled,
actionsRef,
}: MessageEntryProps) {
// The return divider (BUG-1250) is a marker, not an answer: no row, no actions.
const returnRecord = message.rectificationReturn;
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
&& Boolean(message.text)
&& !returnRecord;
const feedbackKey = `${sessionId}:${message.renderKey}`;
// 不满意原因 opens right after a 👎 in this view; stored 👎 ratings do not reopen it.
const [reasonOpen, setReasonOpen] = useState(false);
const latestRegeneratableKey = !loading && !cancellationPending
? [...views].reverse().find((item) => (
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
item.role === "assistant" && item.state === "settled" && Boolean(item.text) && !item.rectificationReturn
))?.renderKey
: undefined;
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
@@ -112,8 +123,23 @@ function MessageEntry({
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
})
: [];
if (returnRecord) {
return (
<div className="message-entry message-entry--divider">
<RectificationReturnDivider
record={returnRecord}
disabled={productEntrypointsDisabled || loading || cancellationPending}
onRevisit={(question) => actionsRef.current.onFollowUp(question)}
/>
</div>
);
}
// P2: answers written before the birth time changed keep their text and get one line above.
const beforeBirthTimeChange = message.role === "assistant" && lastBirthTimeChangeIndex(views) > index;
const offer = sessionType === "consultation" && message.state === "settled" ? message.rectificationOffer : undefined;
return (
<div className="message-entry">
{beforeBirthTimeChange ? <BeforeBirthTimeChangeLabel /> : null}
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
@@ -134,6 +160,13 @@ function MessageEntry({
{showActions && reasonOpen && (
<ReplyDownReason feedbackKey={feedbackKey} onClose={() => setReasonOpen(false)} />
)}
{offer ? (
<RectificationOfferCard
offer={offer}
disabled={productEntrypointsDisabled || loading || cancellationPending}
onStart={() => actionsRef.current.onStartRectification(sessionId)}
/>
) : null}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
@@ -40,7 +40,8 @@ export type RectificationChatPanel = Readonly<{
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
pendingConsultationQuestion?: string | null;
canReturnToSource?: boolean;
onReturnToSource?: () => void;
headerSlot: HTMLElement | null;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
@@ -66,6 +66,9 @@ import {
repairRectificationQuestion,
} from "@/lib/rectification-chat-question-repair";
import { deriveRectificationChatView } from "@/lib/rectification-chat-view";
import { useRectificationAutoReturn } from "@/hooks/use-rectification-return";
import { RECTIFICATION_RETURN_BUTTON_LABEL } from "@/lib/rectification-in-chat";
import { isTerminalRectificationStatus } from "@/lib/rectification-entry";
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
@@ -92,7 +95,9 @@ type RectificationAgenticChatProps = Readonly<{
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
/** This case was started from a consultation that it can go back to (BUG-1250). */
canReturnToSource?: boolean;
onReturnToSource?: () => void;
onRestart?: () => void;
headerSlot: HTMLElement | null;
}>;
@@ -124,7 +129,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onOpenBilling,
onSaved,
onOpeningConsumed,
pendingConsultationQuestion,
canReturnToSource = false,
onReturnToSource,
onRestart,
headerSlot,
} = props;
@@ -437,6 +443,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionRetryAttempts,
savedTime,
});
useRectificationAutoReturn({
enabled: canReturnToSource,
ready: questionGap === "verified_idle" || isTerminalRectificationStatus(caseStatus ?? ""),
busy,
onReturn: onReturnToSource,
});
// Same render-time store update as seededTurns: the lock must be current
// before this paint (BUG-620). A ref write here trips react-hooks/refs.
if (nextOfferLock !== selectionOfferLock) {
@@ -552,11 +564,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{resultNotice && canRecompare && !historyReadonly && (
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
)}
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
@@ -645,6 +652,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{conversationAnchor.latestBelowFold && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{canReturnToSource && (
<div className="rectification-return-row">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onReturnToSource}>
{RECTIFICATION_RETURN_BUTTON_LABEL}
</Button>
</div>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
RECTIFICATION_BEFORE_CHANGE_LABEL,
RECTIFICATION_OFFER_CONTINUE_LABEL,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
rectificationOfferCardCopy,
rectificationReturnDividerText,
rectificationRevisitLabel,
type RectificationOfferRecord,
type RectificationReturnRecord,
} from "@/lib/rectification-in-chat";
/**
* The card under a consultation answer that offered birth-time rectification
* (BUG-1248). The server decided it may be shown and wrote the price; the
* click opens the case (the charge comes with the first answered question).
* A card whose case is open says so and offers to go back in; a card whose
* case has returned is gone (the divider below says what happened).
*/
export function RectificationOfferCard({
offer,
disabled,
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
const opened = offer.status === "opened";
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
<p>{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}</p>
<Button type="button" variant={opened ? "outline" : "default"} size="sm" disabled={disabled} onClick={onStart}>
{opened ? RECTIFICATION_OFFER_CONTINUE_LABEL : RECTIFICATION_OFFER_START_LABEL}
</Button>
</div>
);
}
/**
* Where the reader came back from rectification (BUG-1250). Re-asking with the
* new time is a button and an ordinary consultation turn: nothing is sent, and
* nothing is charged, until it is clicked.
*/
export function RectificationReturnDivider({
record,
disabled,
onRevisit,
}: Readonly<{ record: RectificationReturnRecord; disabled: boolean; onRevisit: (question: string) => void }>) {
return (
<div className="rectification-return-divider" role="note">
<p className="rectification-return-divider__line">{rectificationReturnDividerText(record)}</p>
{record.adopted ? (
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onRevisit(record.sourceQuestion)}>
{rectificationRevisitLabel(record.sourceQuestion)}
</Button>
) : null}
</div>
);
}
/** The small line above an answer written with the birth time before the change (P2). */
export function BeforeBirthTimeChangeLabel() {
return <p className="message-before-change">{RECTIFICATION_BEFORE_CHANGE_LABEL}</p>;
}
+4 -37
View File
@@ -1,8 +1,6 @@
"use client";
import { Clock3, MoonStar } from "lucide-react";
import { useCurrentSubject } from "@/lib/current-subject";
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
import { MoonStar } from "lucide-react";
/**
* The empty state is split across two slots on purpose.
@@ -46,26 +44,14 @@ export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrend
);
}
/* The line under the pills only appears when there is something to do about
it: a non-self subject cannot start a rectification. A resumable case says
nothing here any more (2026-09-30, product: 「这个提示可以去掉」); the case
is still in the history list. */
export function rectificationEntryHint(rectificationForSelf: boolean): string | null {
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
return null;
}
/* The homepage has no 生时校正 pill any more (2026-10-07, BUG-1247): a
rectification starts from a consultation, when the agent offers it under an
answer. Old cases stay in the history list. */
export type StarterEntriesProps = {
readonly natalMinuteAvailable: boolean;
readonly dailyStarlanguageQuestion: string;
readonly productEntrypointsDisabled: boolean;
readonly startDailyStarlanguageConsultation: () => void;
readonly rectificationCardLabel: string;
readonly rectificationLoading: boolean;
readonly rectificationMutationPending: boolean;
readonly openRectificationFromHomepage: () => void;
/** Still passed by the page; the hint no longer reads it (2026-09-30). */
readonly rectificationEntrySummary: RectificationEntrySummary | null;
};
export function StarterEntries({
@@ -73,15 +59,8 @@ export function StarterEntries({
dailyStarlanguageQuestion,
productEntrypointsDisabled,
startDailyStarlanguageConsultation,
rectificationCardLabel,
rectificationLoading,
rectificationMutationPending,
openRectificationFromHomepage,
}: StarterEntriesProps) {
const subjectId = useCurrentSubject();
const rectificationForSelf = subjectId === "self";
const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势";
const rectificationHint = rectificationEntryHint(rectificationForSelf);
return (
<div className="starter-entries">
@@ -96,19 +75,7 @@ export function StarterEntries({
<MoonStar aria-hidden="true" />
<span>{dailyLabel}</span>
</button>
<button
className="starter-entry"
type="button"
aria-label={rectificationCardLabel}
data-opening={rectificationLoading ? "true" : undefined}
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending || !rectificationForSelf}
onClick={() => void openRectificationFromHomepage()}
>
<Clock3 aria-hidden="true" />
<span>生时校正</span>
</button>
</div>
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
{!natalMinuteAvailable && (
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
)}