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