fix: isolate rectification UI mutations

This commit is contained in:
Jesse_Chen
2026-07-21 04:48:30 +08:00
parent 741a395a77
commit c68f4e5f4f
5 changed files with 444 additions and 53 deletions
+1 -1
View File
@@ -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,