fix(rectification): show natal houses and lagna changes in a live right rail

The board was live data sitting under chat, so each new event looked like nothing changed. Keep candidate cards in the transcript and group 换升 by minute for the side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 14:24:05 +08:00
co-authored by Cursor
parent 19de0bab36
commit e07d1bada4
10 changed files with 985 additions and 130 deletions
@@ -1,7 +1,7 @@
"use client";
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
@@ -17,6 +17,10 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
@@ -30,8 +34,7 @@ import {
toggleChatMessageFeedback,
} from "./chat-message-actions";
import { ModelSelector } from "./model-selector";
import { RectificationHouseTableView } from "./rectification-house-table";
import { TechniqueAuditDisclosure, ConfirmationGateDisclosure } from "./technique-audit-disclosure";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -104,75 +107,6 @@ function RectificationCandidateCards({
);
}
function RectificationRefinementPanel({
result,
}: Readonly<{ result: RectificationCandidateResult }>) {
const transitions = result.windowTransitions;
const ledger = result.eventDashaLedger;
const agreement = result.dashaAgreement;
const contrast = result.lagnaContrast;
const nakshatra = result.nakshatraBoundary;
const stage = result.precisionStage;
if (
transitions.length === 0
&& ledger.length === 0
&& !agreement
&& !contrast
&& !nakshatra?.near_boundary
&& !stage
) return null;
return (
<section className="rectification-refinement" aria-label="校正收窄观察">
{stage && (
<p className="rectification-refinement__stage">{stage.user_meaning}</p>
)}
{transitions.length > 0 && (
<div>
<strong></strong>
<ul>
{transitions.map((item) => (
<li key={`${item.layer}-${item.at}`}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{contrast && (
<div>
<strong></strong>
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
)}
{ledger.length > 0 && (
<div>
<strong></strong>
<ul>
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{agreement && (
<p>{agreement.user_meaning}</p>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<div>
<strong></strong>
<p>{nakshatra.user_meaning}</p>
</div>
)}
</section>
);
}
function RectificationOosPanel({
prompts,
}: Readonly<{ prompts: RectificationCandidateResult["oosBlindPrompts"] }>) {
@@ -319,9 +253,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
useLayoutEffect(() => {
const node = workspace.current;
if (!node) return;
const update = () => {
const nextCompact = node.clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
@@ -684,9 +646,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const canSend = !busy && !readonly && !regeneratingMessageKey;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list" aria-live="polite">
<div
ref={workspace}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
{pendingConsultationQuestion.trim()}
@@ -746,19 +712,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{candidateResult?.houseTable && (
<div className="rectification-snapshot">
{candidateResult.natalRecast && (
<p className="rectification-refinement__stage">{candidateResult.natalRecast.user_meaning}</p>
)}
<RectificationHouseTableView table={candidateResult.houseTable} />
<ConfirmationGateDisclosure gate={candidateResult.confirmationGate} />
{savedStatus === "accepted" && candidateResult.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={candidateResult.techniqueAudit} />
)}
</div>
)}
{candidateResult && <RectificationRefinementPanel result={candidateResult} />}
{savedTime && (
<div className="rectification-saved-wrap">
<p className="rectification-saved" role="status">
@@ -786,8 +739,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
</section>
<div className="composer-wrap">
<form className="composer" onSubmit={submit}>
<div className="composer-wrap">
{compactBoard && (
<RectificationBoardPeek
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={() => setBoardOpen(true)}
/>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
@@ -817,7 +778,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onSelect={onSelectModel}
/>
</div>
</div>
</div>
</>
<RectificationBoard
result={candidateResult}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}