fix(rectification): keep collect chips, timeline, and adopt cards aligned
Move 没有/记不清 under the spoken question, inset the timeline to the assistant column, and keep the three-column card after 更像这个 with the closing line under it (BUG-618–620). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1518,18 +1518,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.composer-queue__recall:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
|
||||
|
||||
.composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
|
||||
.rectification-collect-replies {
|
||||
width: min(760px, 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: 0 auto var(--space-2);
|
||||
}
|
||||
.rectification-collect-reply {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
/* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top
|
||||
edge so it never reflows the transcript or the composer. */
|
||||
.jump-to-latest {
|
||||
@@ -2698,6 +2686,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
The pending state keeps the same box for the same reason. */
|
||||
.rectification-workspace__chat {
|
||||
--rectification-timeline-height: 64px;
|
||||
/* Same token as `.message-list`: avatar 32px + gap. The bar sits outside
|
||||
the transcript, so it cannot inherit that custom property from there. */
|
||||
--assistant-content-inset: calc(32px + var(--space-3));
|
||||
}
|
||||
.rectification-timeline {
|
||||
height: var(--rectification-timeline-height);
|
||||
@@ -2706,7 +2697,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-5);
|
||||
/* Match the assistant column (message-list gutter + avatar inset), not the
|
||||
chat-panel edge. The full-width background and hairline stay on the bar. */
|
||||
padding: 0 calc(var(--space-8) + var(--assistant-content-inset));
|
||||
background: var(--color-canvas);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
@@ -2804,7 +2797,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
--rectification-timeline-height: 56px;
|
||||
}
|
||||
.rectification-timeline {
|
||||
padding-inline: var(--space-4);
|
||||
padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
|
||||
}
|
||||
}
|
||||
.rectification-workspace__chat .conversation {
|
||||
@@ -3089,6 +3082,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-collect-replies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-collect-reply {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
/* The gap between a settled turn and its next question, as the last entry of the
|
||||
transcript: a live timeline row while refetching, then copy and a reload. */
|
||||
.rectification-question-gap {
|
||||
@@ -3382,6 +3387,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
text-align: left;
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-pending-note {
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-saved {
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin: 8px 0 16px;
|
||||
|
||||
@@ -107,9 +107,12 @@ import {
|
||||
applyLiveCandidateOffer,
|
||||
copyTextForMessage,
|
||||
interviewQuestionBlocksAdoptOffer,
|
||||
nextSelectionCardLock,
|
||||
parseTurnQuestion,
|
||||
persistedOfferFromTurn,
|
||||
questionIsAnswered,
|
||||
resolveSelectionCardMessageKey,
|
||||
type SelectionCardLock,
|
||||
type TurnQuestion,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -184,6 +187,33 @@ function RectificationReadonlyRange({
|
||||
);
|
||||
}
|
||||
|
||||
function CollectSpokenReplies({
|
||||
onChoose,
|
||||
}: Readonly<{
|
||||
onChoose: (text: "没有" | "记不清") => void;
|
||||
}>) {
|
||||
return (
|
||||
<div className="rectification-collect-replies" role="group" aria-label="采集题快捷回答">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rectification-collect-reply"
|
||||
onClick={() => onChoose("没有")}
|
||||
>
|
||||
没有
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rectification-collect-reply"
|
||||
onClick={() => onChoose("记不清")}
|
||||
>
|
||||
记不清
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
type RectificationAgenticChatProps = Readonly<{
|
||||
caseId: string;
|
||||
@@ -489,6 +519,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const stepStartedAtRef = useRef(0);
|
||||
const liveToolRef = useRef<string | null>(null);
|
||||
const liveBaseLabelRef = useRef("正在处理…");
|
||||
const selectionOfferLockRef = useRef<SelectionCardLock | null>(null);
|
||||
const [compactBoard, setCompactBoard] = useState(false);
|
||||
const [boardOpen, setBoardOpen] = useState(false);
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
@@ -1425,12 +1456,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
stage: caseStage,
|
||||
});
|
||||
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
|
||||
const selectionCardMessageKey = persistedOfferKey
|
||||
const liveSelectionCardKey = persistedOfferKey
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
selectionOfferLockRef.current = nextSelectionCardLock(selectionOfferLockRef.current, {
|
||||
resultId: candidateResult?.resultId,
|
||||
key: liveSelectionCardKey,
|
||||
});
|
||||
const selectionCardMessageKey = resolveSelectionCardMessageKey({
|
||||
persistedOfferKey,
|
||||
fallbackKey: latestSettledAssistant?.renderKey,
|
||||
locked: selectionOfferLockRef.current,
|
||||
resultId: candidateResult?.resultId,
|
||||
selectedTime: candidateResult?.selectedTime,
|
||||
canOffer: canOfferCards,
|
||||
});
|
||||
// Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card
|
||||
// for `busy` is what made the three columns vanish on 「更像这个」.
|
||||
const keepSelectionCardsWhileBusy = Boolean(
|
||||
acceptingCandidateId || candidateResult?.selectedTime,
|
||||
);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult
|
||||
&& caseSnapshotLoaded
|
||||
&& !busy
|
||||
&& (!busy || keepSelectionCardsWhileBusy)
|
||||
&& selectionCardMessageKey
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime))
|
||||
&& !messages.some((message) => (
|
||||
@@ -1472,6 +1520,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
retryAttempts: questionRetryAttempts,
|
||||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
});
|
||||
const verifiedIdleCopy = questionGap === "verified_idle"
|
||||
? postAdoptVerifyDoneCopy(
|
||||
savedTime,
|
||||
messages.some((message) => message.question?.kind === "reverse_verify"),
|
||||
)
|
||||
: null;
|
||||
const conversationState = rectificationConversationState({
|
||||
messageCount: messages.length,
|
||||
busy,
|
||||
@@ -1642,6 +1696,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{liveQuestion && question.kind === "collect_spoken" && (
|
||||
<CollectSpokenReplies onChoose={(text) => void send("message", text)} />
|
||||
)}
|
||||
{question.status === "skipped" && (
|
||||
<p className="rectification-question-skipped" role="status">
|
||||
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
|
||||
@@ -1678,12 +1735,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
/>
|
||||
)}
|
||||
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
|
||||
<RectificationRangeDelivery
|
||||
result={candidateResult}
|
||||
acceptingCandidateId={acceptingCandidateId}
|
||||
readonly={readonly || busy || regeneratingMessageKey !== null}
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
/>
|
||||
<>
|
||||
<RectificationRangeDelivery
|
||||
result={candidateResult}
|
||||
acceptingCandidateId={acceptingCandidateId}
|
||||
readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)}
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
/>
|
||||
{verifiedIdleCopy && (
|
||||
<p className="rectification-pending-note" role="status">
|
||||
{verifiedIdleCopy}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
|
||||
<RectificationReadonlyRange
|
||||
@@ -1706,12 +1770,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "verified_idle" && (
|
||||
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
|
||||
<p className="rectification-pending-note" role="status">
|
||||
{postAdoptVerifyDoneCopy(
|
||||
savedTime,
|
||||
messages.some((message) => message.question?.kind === "reverse_verify"),
|
||||
)}
|
||||
{verifiedIdleCopy}
|
||||
</p>
|
||||
)}
|
||||
{savedTime && savedStatus === "confirmed" && (
|
||||
@@ -1733,26 +1794,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{!conversationAnchor.anchored && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
{collectSpokenPrompt && !busy && !readonly && regeneratingMessageKey === null ? (
|
||||
<div className="rectification-collect-replies" role="group" aria-label="采集题快捷回答">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rectification-collect-reply"
|
||||
onClick={() => void send("message", "没有")}
|
||||
>
|
||||
没有
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rectification-collect-reply"
|
||||
onClick={() => void send("message", "记不清")}
|
||||
>
|
||||
记不清
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<ChatComposer
|
||||
inputRef={composer}
|
||||
value={draft}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function RectificationRangeDelivery({
|
||||
type="button"
|
||||
className="rectification-range-delivery__accept"
|
||||
aria-pressed={adopted}
|
||||
disabled={busy}
|
||||
disabled={busy || adopted}
|
||||
onClick={() => onAccept(column.candidate_id)}
|
||||
>
|
||||
{adopting
|
||||
|
||||
@@ -195,6 +195,39 @@ export function persistedOfferFromTurn(
|
||||
return previous;
|
||||
}
|
||||
|
||||
export type SelectionCardLock = Readonly<{ resultId: string; key: string }>;
|
||||
|
||||
/** Keep the offering message once the reader has seen the card for this result. */
|
||||
export function nextSelectionCardLock(
|
||||
current: SelectionCardLock | null,
|
||||
input: { resultId: string | null | undefined; key: string | undefined },
|
||||
): SelectionCardLock | null {
|
||||
if (!input.resultId) return null;
|
||||
if (input.key) return { resultId: input.resultId, key: input.key };
|
||||
if (current?.resultId === input.resultId) return current;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After adopt, do not re-anchor onto the follow-up turn. Prefer a live offer,
|
||||
* then the pre-adopt fallback, then the lock from when the card first appeared.
|
||||
*/
|
||||
export function resolveSelectionCardMessageKey(input: {
|
||||
persistedOfferKey: string | undefined;
|
||||
fallbackKey: string | undefined;
|
||||
locked: SelectionCardLock | null;
|
||||
resultId: string | null | undefined;
|
||||
selectedTime: string | null | undefined;
|
||||
canOffer: boolean;
|
||||
}): string | undefined {
|
||||
if (!input.resultId) return undefined;
|
||||
const live = input.persistedOfferKey
|
||||
?? (input.canOffer && !input.selectedTime ? input.fallbackKey : undefined);
|
||||
if (live) return live;
|
||||
if (input.locked?.resultId === input.resultId) return input.locked.key;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assistantCanOwnAdoptOffer(
|
||||
turn: { role?: string; question?: TurnQuestion | null },
|
||||
adopted: boolean,
|
||||
|
||||
Reference in New Issue
Block a user