fix: isolate rectification UI mutations
This commit is contained in:
@@ -441,7 +441,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); overflow-wrap: anywhere; color: var(--color-ink); }
|
||||
.conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; }
|
||||
.conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; }
|
||||
.conversational-rectification :where(button, textarea, summary):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; }
|
||||
.conversational-rectification :where(button, textarea, summary, .conversational-status):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; }
|
||||
.conversational-narrative { padding: var(--space-5); border-left: 3px solid var(--color-action); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; background: var(--color-action-soft); }
|
||||
.conversational-narrative .message-markdown { overflow-wrap: anywhere; }
|
||||
.conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; }
|
||||
|
||||
@@ -99,7 +99,9 @@ export function ConversationalRectificationSurface({
|
||||
const abandonTrigger = useRef<HTMLButtonElement>(null);
|
||||
const abandonCancel = useRef<HTMLButtonElement>(null);
|
||||
const abandonConfirm = useRef<HTMLButtonElement>(null);
|
||||
const terminalStatus = useRef<HTMLDivElement>(null);
|
||||
const restoreAbandonFocus = useRef(false);
|
||||
const focusTerminalForCase = useRef<string | null>(null);
|
||||
const turn = controller.turn;
|
||||
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
const abandonIdentity = turn
|
||||
@@ -126,6 +128,19 @@ export function ConversationalRectificationSurface({
|
||||
}
|
||||
}, [abandonArmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedCase = focusTerminalForCase.current;
|
||||
if (!requestedCase) return;
|
||||
if (!turn || turn.caseId !== requestedCase) {
|
||||
focusTerminalForCase.current = null;
|
||||
return;
|
||||
}
|
||||
if (turn.status === "abandoned") {
|
||||
focusTerminalForCase.current = null;
|
||||
terminalStatus.current?.focus();
|
||||
}
|
||||
}, [turn]);
|
||||
|
||||
if (!turn) {
|
||||
return (
|
||||
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
@@ -179,6 +194,14 @@ export function ConversationalRectificationSurface({
|
||||
abandonCancel.current?.focus();
|
||||
}
|
||||
};
|
||||
const confirmAbandon = () => {
|
||||
focusTerminalForCase.current = turn.caseId;
|
||||
void controller.abandon().catch(() => {
|
||||
if (focusTerminalForCase.current === turn.caseId) {
|
||||
focusTerminalForCase.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -300,7 +323,13 @@ export function ConversationalRectificationSurface({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="conversational-status" aria-live="polite">
|
||||
<div
|
||||
aria-label={turn.status === "abandoned" ? "生时校正终态" : undefined}
|
||||
aria-live="polite"
|
||||
className="conversational-status"
|
||||
ref={terminalStatus}
|
||||
tabIndex={turn.status === "abandoned" ? -1 : undefined}
|
||||
>
|
||||
{turn.status === "paused" && <p>校正已暂停,输入与现有证据都已保留。</p>}
|
||||
{turn.status === "abandoned" && <p>本次校正已放弃,候选时间没有应用。</p>}
|
||||
{turn.status === "completed" && turn.candidate.status === "confirmed"
|
||||
@@ -389,7 +418,7 @@ export function ConversationalRectificationSurface({
|
||||
disabled={controller.pending}
|
||||
ref={abandonConfirm}
|
||||
type="button"
|
||||
onClick={() => safely(controller.abandon())}
|
||||
onClick={confirmAbandon}
|
||||
>
|
||||
确认放弃且不应用候选
|
||||
</button>
|
||||
|
||||
@@ -54,6 +54,12 @@ type Mutation = Readonly<{
|
||||
clearDraftOnSuccess?: boolean;
|
||||
}>;
|
||||
|
||||
type ActiveMutation = Readonly<{
|
||||
caseContext: number;
|
||||
token: symbol;
|
||||
promise: MutationResult;
|
||||
}>;
|
||||
|
||||
function createLatestControllerInput(initial: ControllerInput) {
|
||||
let current = initial;
|
||||
return {
|
||||
@@ -94,7 +100,7 @@ export function createConversationalRectificationController(
|
||||
pending: false,
|
||||
error: "",
|
||||
};
|
||||
let activeMutation: MutationResult | null = null;
|
||||
let activeMutation: ActiveMutation | null = null;
|
||||
let caseContext = 0;
|
||||
|
||||
const publish = (next: ConversationalRectificationControllerSnapshot) => {
|
||||
@@ -142,10 +148,13 @@ export function createConversationalRectificationController(
|
||||
turnVersion: turn.turnVersion,
|
||||
}));
|
||||
const run = (mutation: Mutation): MutationResult => {
|
||||
if (activeMutation) return activeMutation;
|
||||
if (activeMutation?.caseContext === caseContext) return activeMutation.promise;
|
||||
patch({ pending: true, error: "" });
|
||||
const turnAtStart = snapshot.turn;
|
||||
const caseContextAtStart = caseContext;
|
||||
const mutationToken = Symbol("conversational-rectification-mutation");
|
||||
const ownsCurrentContext = () => caseContext === caseContextAtStart
|
||||
&& activeMutation?.token === mutationToken;
|
||||
const operation = registry.run(
|
||||
mutation.identity,
|
||||
(actionId) => send(mutation.command(actionId)),
|
||||
@@ -155,25 +164,33 @@ export function createConversationalRectificationController(
|
||||
caseContextAtStart,
|
||||
))
|
||||
.catch(async (error: unknown) => {
|
||||
if (turnAtStart && staleTurn(error)) {
|
||||
if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) {
|
||||
try {
|
||||
const recovered = await recoverLatest(turnAtStart);
|
||||
return acceptTurn(recovered, false, caseContextAtStart);
|
||||
} catch (recoveryError) {
|
||||
patch({ error: displayError(recoveryError) });
|
||||
if (ownsCurrentContext()) patch({ error: displayError(recoveryError) });
|
||||
throw recoveryError;
|
||||
}
|
||||
}
|
||||
patch({ error: displayError(error) });
|
||||
if (ownsCurrentContext()) patch({ error: displayError(error) });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeMutation?.token !== mutationToken) return;
|
||||
activeMutation = null;
|
||||
patch({ pending: false });
|
||||
if (caseContext === caseContextAtStart) patch({ pending: false });
|
||||
});
|
||||
activeMutation = operation;
|
||||
activeMutation = {
|
||||
caseContext: caseContextAtStart,
|
||||
token: mutationToken,
|
||||
promise: operation,
|
||||
};
|
||||
return operation;
|
||||
};
|
||||
const activeMutationForCurrentContext = () => (
|
||||
activeMutation?.caseContext === caseContext ? activeMutation.promise : null
|
||||
);
|
||||
const currentTurn = () => snapshot.turn;
|
||||
const currentMutation = (
|
||||
operation: Exclude<ConversationalRectificationCommand["type"], "start">,
|
||||
@@ -211,12 +228,14 @@ export function createConversationalRectificationController(
|
||||
if (turn === null) {
|
||||
if (current === null) return;
|
||||
caseContext += 1;
|
||||
patch({ turn: null, draft: "", selectedDomain: null, error: "" });
|
||||
activeMutation = null;
|
||||
patch({ turn: null, draft: "", selectedDomain: null, pending: false, error: "" });
|
||||
return;
|
||||
}
|
||||
if (current === null || current.caseId !== turn.caseId) {
|
||||
caseContext += 1;
|
||||
patch({ turn, draft: "", selectedDomain: null, error: "" });
|
||||
activeMutation = null;
|
||||
patch({ turn, draft: "", selectedDomain: null, pending: false, error: "" });
|
||||
return;
|
||||
}
|
||||
if (turn.turnVersion <= current.turnVersion) return;
|
||||
@@ -270,7 +289,9 @@ export function createConversationalRectificationController(
|
||||
},
|
||||
pause() {
|
||||
const turn = currentTurn();
|
||||
if (!turn?.actions.includes("pause")) return activeMutation ?? Promise.resolve(turn);
|
||||
if (!turn?.actions.includes("pause")) {
|
||||
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
|
||||
}
|
||||
return currentMutation("pause", {}, (current, actionId) => ({
|
||||
type: "pause",
|
||||
caseId: current.caseId,
|
||||
@@ -280,7 +301,9 @@ export function createConversationalRectificationController(
|
||||
},
|
||||
abandon() {
|
||||
const turn = currentTurn();
|
||||
if (!turn?.actions.includes("abandon")) return activeMutation ?? Promise.resolve(turn);
|
||||
if (!turn?.actions.includes("abandon")) {
|
||||
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
|
||||
}
|
||||
return currentMutation("abandon", {}, (current, actionId) => ({
|
||||
type: "abandon",
|
||||
caseId: current.caseId,
|
||||
@@ -292,7 +315,9 @@ export function createConversationalRectificationController(
|
||||
const turn = currentTurn();
|
||||
const candidateTime = time ?? turn?.candidate.representativeTime ?? null;
|
||||
if (!turn?.actions.includes("confirm") || turn.candidate.status !== "ready_for_confirmation"
|
||||
|| !candidateTime) return activeMutation ?? Promise.resolve(turn);
|
||||
|| !candidateTime) {
|
||||
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
|
||||
}
|
||||
return currentMutation("confirm", { time: candidateTime }, (current, actionId) => ({
|
||||
type: "confirm",
|
||||
caseId: current.caseId,
|
||||
|
||||
Reference in New Issue
Block a user