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
@@ -1,43 +0,0 @@
/**
* Sticky follow for a streaming message viewport.
*
* Auto-follow only when the user is already near the bottom. Streaming
* updates must not steal scrollTop after the user moves upward.
*/
export const RECTIFICATION_NEAR_BOTTOM_PX = 96;
/** Keep the jump chip hidden while a choice card still occupies the composer overlay band. */
export const RECTIFICATION_CHOICE_NEAR_BOTTOM_PX = 360;
export function distanceFromBottom(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
): number {
return scrollHeight - scrollTop - clientHeight;
}
export function isNearBottom(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
thresholdPx = RECTIFICATION_NEAR_BOTTOM_PX,
): boolean {
return distanceFromBottom(scrollHeight, scrollTop, clientHeight) <= thresholdPx;
}
export function shouldShowJumpToLatest(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
choiceCardOpen = false,
): boolean {
const distance = distanceFromBottom(scrollHeight, scrollTop, clientHeight);
if (distance <= RECTIFICATION_NEAR_BOTTOM_PX) return false;
if (choiceCardOpen && distance <= RECTIFICATION_CHOICE_NEAR_BOTTOM_PX) return false;
return true;
}
export function shouldFollowLatest(followTail: boolean): boolean {
return followTail === true;
}