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
@@ -43,7 +43,11 @@ test("the count region is polite, atomic, and only mounted inside the threshold"
});
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
assert.match(composer, /aria-describedby=\{showRemaining \? composerRemainingId : undefined\}/);
// Former lock: `aria-describedby={showRemaining ? composerRemainingId : undefined}`. The composer
// is now shared with the rectification surface, which passes its own count id (BUG-477).
assert.match(composer, /remainingId = composerRemainingId/);
assert.match(composer, /showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/);
assert.match(composer, /aria-describedby=\{describedByIds\}/);
assert.match(page, /<ComposerCharacterRemaining maxLength=\{!profileComplete && onboardingStep === "name" \? 80 : 500\} \/>/);
assert.match(page, /className="composer-footer"/);
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
@@ -204,25 +204,36 @@ test("aria-busy marks the updating message list without swallowing the status re
});
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
const buttonTag = sourceBetween(jumpControl, "<button", ">");
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonTag = sourceBetween(jumpSource, "<button", ">");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpControl, /<button\b/);
assert.match(jumpSource, /<button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
assert.doesNotMatch(buttonTag, /disabled/);
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /\n\s*跳到最新\n/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
assert.match(jumpSource, /aria-label="跳到最新"/);
assert.match(jumpSource, /\n\s*跳到最新\n/);
assert.match(styles, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
assert.match(buttonRule, /min-height: 44px/);
assert.match(buttonRule, /min-width: 44px/);
// And: the icon is decorative, and pointer-events never gate keyboard activation.
assert.match(jumpControl, /<ArrowDown aria-hidden="true"/);
assert.match(jumpControl, /pointer-events-auto/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
assert.match(jumpSource, /<ArrowDown aria-hidden="true"/);
assert.match(wrapRule, /pointer-events: none/);
assert.match(buttonRule, /pointer-events: auto/);
assert.match(jumpSource, /onClick=\{onClick\}/);
});
@@ -68,17 +68,19 @@ test("assigns notice severity by message intent", () => {
});
test("anchors the streaming scroll instead of following every token", () => {
const autoScrollEffect = sourceBetween(
pageSource,
"useEffect(() => {\n if (starterHomeVisible) return;",
"profileComplete, starterHomeVisible]);",
);
// Then: streamed tokens only move the viewport while the reader stays anchored.
assert.match(autoScrollEffect, /if \(!conversationAnchor\.anchored\) return/);
const scrollGuard = autoScrollEffect.indexOf("if (!conversationAnchor.anchored) return");
const scrollCall = autoScrollEffect.indexOf("container.scrollTo(");
// Former lock: a page-level effect keyed on `activeStreamingText` that called
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
// a second smooth scroll on settle; the follow now lives in the hook, driven by a
// ResizeObserver over the scroller's children, one frame per change (BUG-478).
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
const follow = sourceBetween(anchorSource, "const follow = () => {", "};");
assert.match(follow, /if \(!anchoredRef\.current\) return/);
const scrollGuard = follow.indexOf("if (!anchoredRef.current) return");
const scrollCall = follow.indexOf("element.scrollTop = element.scrollHeight");
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
});
test("scrolls to the newest turn on intentional jumps", () => {
@@ -92,15 +94,22 @@ test("scrolls to the newest turn on intentional jumps", () => {
});
test("offers an accessible jump-to-latest control while reading history", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
assert.match(jumpControl, /type="button"/);
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(jumpSource, /type="button"/);
assert.match(jumpSource, /aria-label="跳到最新"/);
assert.match(globalsSource, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
assert.match(buttonRule, /min-height: 44px/);
assert.match(buttonRule, /min-width: 44px/);
assert.match(buttonRule, /box-shadow: var\(--shadow-elevated\)/);
});
test("rests the jump-to-latest control on the composer instead of the padded scroller", () => {
@@ -111,9 +120,11 @@ test("rests the jump-to-latest control on the composer instead of the padded scr
// Then: the control hangs off the composer's own top edge, whatever that reserve is.
const composerWrap = sourceBetween(pageSource, "className={`composer-wrap ", "<ChatComposer");
const jumpControl = sourceBetween(composerWrap, "{jumpToLatestVisible && (", "</button>");
assert.match(jumpControl, /absolute inset-x-0 bottom-full/);
assert.doesNotMatch(jumpControl, /sticky/);
assert.match(composerWrap, /<JumpToLatestButton/);
const wrapRule = globalsSource.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
assert.match(wrapRule, /position: absolute/);
assert.match(wrapRule, /bottom: 100%/);
assert.doesNotMatch(wrapRule, /sticky/);
});
test("keeps the scroll listener passive and reduced-motion aware", () => {
+4 -1
View File
@@ -152,7 +152,10 @@ test("nothing sits between the transcript and the composer to shift height while
pageSource.indexOf("<ChatComposer"));
assert.doesNotMatch(composerWrap, /composer-suggestions/);
assert.match(composerWrap, /pointer-events-none absolute inset-x-0 bottom-full/);
// Former lock: the inline `pointer-events-none absolute inset-x-0 bottom-full` wrapper. The
// control is now the shared JumpToLatestButton, positioned by `.jump-to-latest` (BUG-478).
assert.match(composerWrap, /<JumpToLatestButton/);
assert.match(globalStyles, /\.jump-to-latest \{[^}]*position: absolute[^}]*bottom: 100%/);
});
test("docks the composer inside the chat panel instead of floating over content", () => {
@@ -42,7 +42,16 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
// When: the composer resolves the draft itself.
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
assert.match(composerSource, /const draft = useComposerDraft\(\)/);
// Former lock: `const draft = useComposerDraft()`. The composer still owns the store
// subscription; a surface with its own draft (rectification) may pass `value` instead, and
// must never write the main chat's store (BUG-477).
assert.match(composerSource, /const storeDraft = useComposerDraft\(\);\n\s*const draft = value \?\? storeDraft;/);
const rectificationSource = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(rectificationSource, /<ChatComposer[\s\S]*?value=\{draft\}/);
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
@@ -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/);
@@ -13,7 +13,7 @@ import {
shouldContinueAfterStructuredChoice,
userVisibleChoiceLine,
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { conversationAnchorThreshold, nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
spokenCollectFallbackFollowup,
@@ -542,17 +542,15 @@ function choiceAccounting(overrides: Parameters<typeof fakeAccounting>[0] = {})
}
test("does not force-follow when the reader has scrolled upward", () => {
assert.equal(isNearBottom(1_000, 100, 400), false);
assert.equal(shouldFollowLatest(false), false);
assert.equal(isNearBottom(1_000, 520, 400), true);
assert.equal(shouldFollowLatest(true), true);
});
test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => {
assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true);
assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false);
assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false);
assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true);
// Former locks exercised `rectification-sticky-scroll.ts` (`isNearBottom`, `shouldFollowLatest`,
// `shouldShowJumpToLatest` with a 360px choice-card band). That module was the second scroll
// implementation (BUG-478); the shared anchor keeps the same 96px near-bottom rule, and the
// jump control shows exactly when the reader is not anchored.
assert.equal(conversationAnchorThreshold, 96);
assert.equal(nextAnchorState(true, 1_000 - 100 - 400, true), false);
assert.equal(nextAnchorState(true, 1_000 - 520 - 400, false), true);
assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), true);
});
test("choice quotes come from the option label, not the assistant question year", () => {
@@ -1125,8 +1123,10 @@ test("the public agent route treats structured choice as a non-model command", (
assert.match(chat, /send\("read_only", ""\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
assert.match(chat, /回到最新/);
assert.match(chat, /followTailRef\.current/);
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
// flag (BUG-478). The shared control and anchor are locked instead.
assert.match(chat, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(chat, /conversationAnchor\.anchored/);
});
test("rectification attempt timeout stays under the agent route budget", () => {