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:
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user