fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start
Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4dc0c8c7b3
commit
91b8b33aa6
@@ -82,9 +82,20 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
|
||||
assert.doesNotMatch(chat, /spoken-answer/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
// Former lock read this parser out of the page surface (`use-rectification-surface.ts`). The
|
||||
// turn parser moved to `rectification-surface-state.ts` so the hook's hydration and the chat's
|
||||
// late-snapshot fill share one (BUG-479); the shape it produces is unchanged.
|
||||
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
||||
assert.match(surfaceState, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
assert.match(page, /parsePersistedRectificationTurns\(payload\?\.turns\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
|
||||
@@ -191,7 +202,13 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
|
||||
assert.match(chat, /initialTurns/);
|
||||
assert.match(chat, /const openingStarted = useRef\(false\)/);
|
||||
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
@@ -218,10 +235,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
|
||||
assert.match(chat, /resultId: candidateResult\.resultId/);
|
||||
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
|
||||
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
|
||||
// Former locks: `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`, the
|
||||
// `if (!choiceContinuationPending.current || busy || readonly) return; … void send("read_only", "")`
|
||||
// effect, and a doesNotMatch on `await loadCaseSnapshot(); … await send("read_only", "")`. That
|
||||
// effect hop dropped the live row for a frame and let the next card flash in (BUG-480); the
|
||||
// follow-up turn now continues in the same async chain on the same row, still through the
|
||||
// durable case endpoint first.
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
||||
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
|
||||
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
||||
assert.doesNotMatch(chat, /action: "accept_candidate"/);
|
||||
const acceptRoute = readFileSync(
|
||||
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
|
||||
@@ -501,7 +524,9 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
|
||||
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
|
||||
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
|
||||
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
|
||||
// Former lock: the workspace class expression ended at ` is-board-open`. The board now also
|
||||
// carries `is-board-empty` before any candidate exists, so the empty board takes less width (BUG-483).
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}\$\{candidateResult \? "" : " is-board-empty"\}`\}/);
|
||||
});
|
||||
|
||||
test("rectification composer can stop a live agent run", () => {
|
||||
@@ -554,7 +579,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.match(choiceCardComponent, /selectedKey/);
|
||||
assert.match(choiceCardComponent, /is-answered/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
|
||||
assert.match(chat, /isStructuredChoiceUserText/);
|
||||
// Former lock: `isStructuredChoiceUserText` in the chat — it filtered the reader's tapped choice out
|
||||
// of the persisted transcript, so after a refresh nobody could see what they had answered (BUG-482).
|
||||
// The tap is now echoed as the reader's line, live and persisted alike.
|
||||
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
||||
assert.match(chat, /userVisibleChoiceLine\(answeredCard, optionId\)/);
|
||||
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
|
||||
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
|
||||
assert.match(route, /answer_choice/);
|
||||
|
||||
Reference in New Issue
Block a user