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:
co-authored by
Claude Opus 5.5
parent
328f919d5d
commit
6ad0bea320
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user