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
+36 -27
View File
@@ -1485,6 +1485,42 @@ button:disabled { cursor: default; opacity: .45; }
.error-message { margin: 12px 0 0; padding: 12px 14px; border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: 13px; line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; }
.composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
/* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top
edge so it never reflows the transcript or the composer. */
.jump-to-latest {
position: absolute;
inset-inline: 0;
bottom: 100%;
z-index: 3;
display: flex;
justify-content: center;
padding-bottom: var(--space-3);
pointer-events: none;
}
.jump-to-latest__button {
pointer-events: auto;
min-height: 44px;
min-width: 44px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: var(--shadow-elevated);
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
transition: background-color 120ms ease-out;
}
.jump-to-latest__button svg { width: 16px; height: 16px; flex: 0 0 auto; }
.jump-to-latest__button:hover { background: var(--color-canvas-muted); }
.jump-to-latest__button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
outline-offset: 3px;
}
.composer { width: min(760px, 100%); display: flex; align-items: flex-end; gap: 10px; margin: 0 auto; border: 1px solid var(--color-border); transition: border-color 140ms ease-out; min-height: 60px; padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); border-color: var(--color-border-strong); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-border) 42%, transparent); }
.composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); }
.composer textarea { min-width: 0; min-height: 44px; max-height: 128px; flex: 1; resize: none; padding: 11px 0 8px; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: 16px; }
@@ -2608,7 +2644,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-workspace__chat .message-list {
width: 100%;
max-width: 720px;
padding-bottom: var(--rectification-jump-clearance);
}
.conversation.is-rectification .message-actions {
@@ -2618,32 +2653,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
position: relative;
z-index: 2;
}
.rectification-jump-latest {
position: absolute;
inset-inline: 0;
bottom: 100%;
z-index: 3;
display: flex;
justify-content: center;
padding-bottom: var(--space-2);
pointer-events: none;
}
.rectification-jump-latest button {
pointer-events: auto;
min-height: 44px;
min-width: 44px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: var(--shadow-soft);
font-size: var(--type-caption);
}
.rectification-jump-latest button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
outline-offset: 3px;
}
.rectification-workspace.is-compact.is-board-open .rectification-workspace__chat .composer-wrap {
z-index: 0;
visibility: hidden;
+3 -21
View File
@@ -4,7 +4,7 @@ import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { ArrowDown, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
@@ -33,6 +33,7 @@ import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-tr
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
import { ComposerCharacterRemaining } from "@/components/composer-character-remaining";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
@@ -1240,15 +1241,6 @@ export default function Home() {
setStarterGreetingSelection(Math.random());
}, [starterHomeVisible]);
useEffect(() => {
if (starterHomeVisible) return;
const container = conversation.current;
if (!container) return;
if (!conversationAnchor.anchored) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" });
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, conversationAnchor.anchored, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
composerInput.current?.focus();
@@ -1924,17 +1916,7 @@ export default function Home() {
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{jumpToLatestVisible && (
<div className="pointer-events-none absolute inset-x-0 bottom-full flex justify-center pb-3">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<ChatComposer
inputRef={composerInput}