refactor(chat): share composer, scroll follow and jump control across both chat surfaces

The rectification session rendered its own textarea (no length ceiling,
no count), its own rAF scroll follow with a sticky-scroll helper, and its
own jump chip styled differently from the main chat's inline Tailwind
button, while the main chat re-ran scrollTo on every streamed token and
added a smooth scroll on settle. ChatComposer now accepts a controlled
value so the rectification surface reuses it without touching the main
draft store; useConversationScrollAnchor owns the follow through a
ResizeObserver, one frame per content change, for both surfaces; a
single JumpToLatestButton replaces both chips; the 720px transcript
override is gone. DESIGN.md records the shared composer, the jump
control, the reading width and the two shadow tokens.

BUG-477 BUG-478

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-01 23:26:23 +00:00
parent eeb82dfdc4
commit f542c02490
17 changed files with 329 additions and 264 deletions
@@ -277,27 +277,19 @@ test("rectification uses one case-level entitlement and the session-pinned model
});
test("Agentic rectification follows the conversation tail only while the reader stays near the bottom", () => {
// Former locks pinned the rectification-only follow (`followTailRef`, `isNearBottom`,
// `shouldShowJumpToLatest`, `choiceCardsOpen`, `followLatestContent`, the 「回到最新」 chip and
// `.rectification-jump-latest`). That was the second scroll implementation beside the
// consultation hook (BUG-478); both surfaces now share `useConversationScrollAnchor` and
// `JumpToLatestButton`. The choice-card band is gone with it: the card sits inside the
// transcript, so a reader looking at it is near the bottom and anchored.
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/);
assert.match(chat, /followTailRef/);
assert.match(chat, /isNearBottom/);
assert.match(chat, /showJumpToLatest/);
assert.match(chat, /回到最新/);
assert.match(chat, /followLatestContent/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: viewport\.scrollHeight/);
assert.match(chat, /shouldShowJumpToLatest/);
assert.match(chat, /choiceCardsOpen/);
assert.match(
chat,
/useLayoutEffect\(\(\) => \{\s*choiceCardsOpen\.current = showLiveChoiceCard;\s*updateFollowState\(\);\s*\}, \[showLiveChoiceCard, updateFollowState\]\)/,
);
assert.doesNotMatch(chat, /choiceCardsOpen\.current = showLiveChoiceCard;\s*useLayoutEffect/);
assert.match(chat, /updateFollowState/);
assert.match(chat, /\}, \[busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent\]\);/);
const jumpLatestRule = styles.match(/\.rectification-jump-latest \{[^}]+\}/)?.[0] ?? "";
assert.match(jumpLatestRule, /justify-content: center/);
assert.doesNotMatch(jumpLatestRule, /flex-end/);
assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/);
assert.match(chat, /\{!conversationAnchor\.anchored && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/);
assert.doesNotMatch(styles, /\.rectification-jump-latest/);
assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/);
assert.match(styles, /--rectification-jump-clearance/);
assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
@@ -515,8 +507,13 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
test("rectification composer can stop a live agent run", () => {
assert.match(chat, /signal: abortController\.signal/);
assert.match(chat, /runAbort\.current\?\.abort\(\)/);
assert.match(chat, /className="composer-stop"/);
assert.match(chat, /aria-label="停止回答"/);
// Former locks: `className="composer-stop"` and `aria-label="停止回答"` inside the chat file —
// the rectification-only composer. The stop control now comes from the shared ChatComposer
// (BUG-477); the chat passes the label and the handler.
assert.match(chat, /stopVisible=\{busy\}/);
assert.match(chat, /stopLabel="停止回答"/);
assert.match(chat, /onStop=\{stopRun\}/);
assert.match(readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /className="composer-stop"/);
assert.match(chat, /event\.type === "attempt\.reset"/);
assert.match(chat, /caught\.name === "AbortError"/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
@@ -576,7 +573,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
});
test("rectification keeps the composer but never renders generated suggestion chips", () => {
assert.match(chat, /<form className="composer"/);
// Former lock: `<form className="composer"` written inline in the chat. The form now comes
// from the shared ChatComposer, with the same 500-character ceiling as the main chat (BUG-477).
assert.match(chat, /<ChatComposer/);
assert.match(chat, /maxLength=\{RECTIFICATION_COMPOSER_MAX_LENGTH\}/);
assert.match(chat, /const RECTIFICATION_COMPOSER_MAX_LENGTH = 500/);
assert.match(chat, /<CharacterRemaining/);
assert.doesNotMatch(chat, /composer-suggestions/);
assert.doesNotMatch(chat, /setSuggestions/);
assert.doesNotMatch(chat, /suggestions/);