diff --git a/DESIGN.md b/DESIGN.md index 906a991..ac19ef0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -295,22 +295,40 @@ Rules: - A collapsed panel keeps its previous width in the session so expanding restores it; `0` is never persisted as a width. -`MessageList` follows the transcript only while the reader is already at the -bottom (within 48px). Scrolling up suspends the follow and reveals a -`jumpToLatest` pill; the follow resumes when they return to the bottom. A stream -that force-scrolls per token makes re-reading impossible. The scroll itself is -instant, never smooth: this effect runs per token, and an animation queued that -often never settles. +`MessageList` follows the transcript only while the reader is already near the +bottom (within `PINNED_THRESHOLD`, 48px). Scrolling up suspends the follow and +reveals a circular, icon-only `jumpToLatest` control centred above the composer; +the follow resumes when they return to the bottom, or when the control is +pressed. A stream that force-scrolls per token makes re-reading impossible. The +follow itself is instant, never smooth, because it runs per token and an +animation queued that often never settles; the control is the one place a smooth +scroll is allowed, and only while no answer is streaming. Reduced motion turns +even that into an instant jump. + +The distance to the bottom is `scrollHeight - scrollTop - clientHeight` clamped at +zero, compared against the threshold — never `scrollTop + clientHeight === +scrollHeight`, which is false under fractional pixels and reflows. The numbers +live in `stickToBottom.ts` and are tested there. The transcript's empty state and its message list must share **one** `ScrollArea`. The scroll subscription runs once, so a viewport that only exists after messages -arrive can never be observed — that is how the follow silently stopped working -while looking correct in review. - -`ProcessPanel` floats the composer over the transcript and reserves `pb-32` -(128px) for it; the scroll fade above it is `h-32` for the same reason. The fade -is sized to the reserve, not to taste: a taller fade dims the last line of every -answer, because the content scrolls under it. +arrive can never be observed. + +The follow is driven by the content's **height**, not only by the messages array: +streaming grows one message, and Markdown reflow, a highlighted code block, a +decoded image or an opened source disclosure change the height with no message +event at all. A `ResizeObserver` on the transcript keeps the follow alive in all +of those, and only while the reader is pinned. + +`ProcessPanel` floats the composer over the transcript, so the transcript reserves +space for it: `COMPOSER_GAP` (24px) plus the composer's own measured height. The +composer's height is **measured**, not assumed — it grows with the scope row and +the auto-resizing textarea, and the old fixed reserve left a long question's answer +behind the input. A single `ResizeObserver` publishes the reserve as +`--composer-reserve` on the panel card; the transcript's `padding-bottom`, the +back-to-bottom control's offset and the scroll fade all read that one value. The +fade is exactly the reserve's height for the same reason: a taller fade dims the +last line of every answer, because the content scrolls under it. Two rules for the composer and for leaving the workspace: @@ -636,7 +654,7 @@ Don't: - Gradients, glassmorphism (`backdrop-blur` except on a floating toolbar over scrolling content), glow effects. **One sanctioned exception:** the scroll fade between the transcript and the floating composer — a functional fade, sized to - the `pb-32` reserve it covers so it never dims the last line of an answer + the measured composer reserve it covers so it never dims the last line of an answer (`ProcessPanel.tsx`). - Colour icons in neutral chrome; colour-coded sections chosen from `--chart-*` outside chart/graph surfaces. diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index e6db01e..964b14b 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -16,6 +16,7 @@ import { parseRetrievalScope } from '../../../../shared/types/scope' import { FOCUS_CHAT_EVENT } from '../../lib/workspaceEvents' import MessageList, { type MessageListHandle } from './chat/MessageList' import ScopeSelector from './chat/ScopeSelector' +import { COMPOSER_GAP, COMPOSER_RESERVE, COMPOSER_RESERVE_VAR } from './chat/stickToBottom' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Textarea } from '../ui/textarea' @@ -58,6 +59,8 @@ function ProcessPanel({ const [isEditingTitle, setIsEditingTitle] = useState(false) const textareaRef = useRef(null) const messageListRef = useRef(null) + const cardRef = useRef(null) + const composerRef = useRef(null) const currentNotebookId = currentSession?.notebookId const isCurrentNotebookStreaming = currentNotebookId @@ -148,6 +151,26 @@ function ProcessPanel({ // 的折叠状态与 zone focus target,并且快捷键的语义已从「显示/隐藏面板」变成 // 「进入该工作区」(#65)。顶部按钮仍然走 `onToggleLeft` / `onToggleRight`。 + // The composer floats over the transcript, so the transcript reserves its + // height plus a gap. Measured rather than fixed: the reserve used to be a + // hardcoded `pb-32` while the composer grew with the scope row and the + // auto-resizing textarea, so a long question left the last answer line behind + // the input. Published as a CSS variable on the card because `MessageList` + // (the reserve) and the fade below both read it. + useEffect(() => { + const composer = composerRef.current + const card = cardRef.current + if (!composer || !card) return + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + const height = entry.borderBoxSize?.[0]?.blockSize ?? composer.getBoundingClientRect().height + card.style.setProperty(COMPOSER_RESERVE_VAR, `${height + COMPOSER_GAP}px`) + }) + observer.observe(composer) + return () => observer.disconnect() + }, []) + // Auto-resize textarea based on content const adjustTextareaHeight = (): void => { const textarea = textareaRef.current @@ -229,7 +252,7 @@ function ProcessPanel({ } return ( - + - + {/* Remount per session: every conversation opens at its own end, not at + the position the previous one happened to be left at. */} + {/* 底部渐变遮罩 - 独立于消息区域,避免堆叠上下文问题 */}
{/* 底部输入区域 - 绝对定位浮动在底部 */} -
+
{/* 范围选择(#94):这次问题用哪些来源回答。放在输入框上方,跟在要提问的地方。 */} {currentSession && ( diff --git a/src/renderer/src/components/notebook/chat/MessageList.tsx b/src/renderer/src/components/notebook/chat/MessageList.tsx index 7380011..dca4031 100644 --- a/src/renderer/src/components/notebook/chat/MessageList.tsx +++ b/src/renderer/src/components/notebook/chat/MessageList.tsx @@ -15,6 +15,7 @@ import { ScrollArea } from '../../ui/scroll-area' import { Button } from '../../ui/button' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty' import { isAnswerLive } from '../../../../../shared/utils/answerState' +import { BACK_TO_BOTTOM_BOTTOM, COMPOSER_RESERVE, isPinnedToBottom } from './stickToBottom' // messageList.css 已合并到 effects.css(通过 main.css 全局导入) export interface MessageListHandle { @@ -26,16 +27,25 @@ interface MessageListProps { messages: ChatMessage[] } -/** How close to the bottom still counts as "following the answer". */ -const PINNED_THRESHOLD = 48 +/** A user who asked for reduced motion gets the jump without the animation. */ +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} const MessageList = forwardRef(function MessageList( { messages }, ref ): ReactElement { const viewportRef = useRef(null) - const bottomRef = useRef(null) + const contentRef = useRef(null) const pinnedRef = useRef(true) + // True while an animated `pinToBottom` is still travelling. The scroll events + // it emits are the animation, not the reader leaving, so they must not + // unfollow the transcript halfway down. + const programmaticRef = useRef(false) const [isPinned, setIsPinned] = useState(true) // Completion announcements. Kept as `{ text, id }` so the node is replaced and // the live region re-announces: setting the same string twice is a no-op for a @@ -47,6 +57,7 @@ const MessageList = forwardRef(function Mes const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined // The record says whether the turn is still arriving (#142). const isStreaming = isAnswerLive(lastMessage?.status) + const hasMessages = messages.length > 0 useEffect(() => { // Announce the END of a turn, never its tokens. A live region on the @@ -62,36 +73,67 @@ const MessageList = forwardRef(function Mes streamingRef.current = isStreaming }, [isStreaming, t]) - const scrollToBottom = useCallback((): void => { - // Deliberately instant, not smooth: while an answer streams this runs per - // token, and an animated scroll queued that often never settles — it fights - // the reader and burns frames instead of following the text. - bottomRef.current?.scrollIntoView({ block: 'end' }) + // Land on the true bottom of the scroll viewport, not on an anchor near it. An + // anchor's `scrollIntoView` stops short of the reserved composer space, which + // is what left the last line behind the composer. + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto'): void => { + const viewport = viewportRef.current + if (!viewport) return + viewport.scrollTo({ top: viewport.scrollHeight, behavior }) }, []) const pinToBottom = useCallback((): void => { pinnedRef.current = true setIsPinned(true) - scrollToBottom() - }, [scrollToBottom]) + const viewport = viewportRef.current + if (!viewport) return + // Smooth for a deliberate jump; instant while an answer streams, where the + // content keeps growing underneath the animation and would cancel it. Already + // at the bottom, there is nothing to animate and no scroll event will arrive + // to clear the lock — so no lock is taken. + const smooth = !isStreaming && !prefersReducedMotion() && !isPinnedToBottom(viewport) + programmaticRef.current = smooth + viewport.scrollTo({ top: viewport.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }) + }, [isStreaming]) useImperativeHandle(ref, () => ({ pinToBottom }), [pinToBottom]) - // Follow the transcript only while the reader is already at the bottom. + // Follow the transcript only while the reader is already near the bottom. useEffect(() => { const viewport = viewportRef.current if (!viewport) return const handleScroll = (): void => { - const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight - const pinned = distance <= PINNED_THRESHOLD + const pinned = isPinnedToBottom(viewport) + if (programmaticRef.current) { + // Still animating: only arrival clears the lock, so the follow re-arms + // the instant the view lands instead of being cancelled part-way. + if (!pinned) return + programmaticRef.current = false + } pinnedRef.current = pinned setIsPinned(pinned) } + // A wheel, a touch drag or a key is the reader taking over; cancel a running + // animation so the next scroll event is judged on its own. + const cancelProgrammatic = (): void => { + programmaticRef.current = false + } + viewport.addEventListener('scroll', handleScroll, { passive: true }) - handleScroll() - return () => viewport.removeEventListener('scroll', handleScroll) + viewport.addEventListener('wheel', cancelProgrammatic, { passive: true }) + viewport.addEventListener('touchmove', cancelProgrammatic, { passive: true }) + viewport.addEventListener('keydown', cancelProgrammatic) + // No initial `handleScroll()`: a fresh viewport sits at the top, and the + // follow effect below is about to move it to the bottom. Reading the + // position here would unfollow before it ever ran. + return () => { + viewport.removeEventListener('scroll', handleScroll) + viewport.removeEventListener('wheel', cancelProgrammatic) + viewport.removeEventListener('touchmove', cancelProgrammatic) + viewport.removeEventListener('keydown', cancelProgrammatic) + } }, []) useEffect(() => { @@ -101,6 +143,23 @@ const MessageList = forwardRef(function Mes scrollToBottom() }, [messages, scrollToBottom]) + // Streaming changes the height of one message, not the number of messages: the + // array above is replaced per token today, but Markdown reflow, a highlighted + // code block, a decoded image or an opened source disclosure change the height + // with no message event at all. Watch the content itself. + useEffect(() => { + if (!hasMessages) return + const content = contentRef.current + if (!content) return + + const observer = new ResizeObserver(() => { + if (!pinnedRef.current) return + scrollToBottom() + }) + observer.observe(content) + return () => observer.disconnect() + }, [hasMessages, scrollToBottom]) + // The empty state and the message list must share ONE ScrollArea: the scroll // subscription above runs once, so a viewport that only appears after messages // arrive would never be observed and the follow would silently never work. @@ -112,7 +171,7 @@ const MessageList = forwardRef(function Mes
- {messages.length === 0 ? ( + {!hasMessages ? (
@@ -125,29 +184,32 @@ const MessageList = forwardRef(function Mes
) : ( -
+ // Observed for height so the follow survives reflow without a message + // event. The reserved space keeps the last line above the composer. +
{messages.map((message) => ( ))} - {/* 滚动锚点 */} -
)} - {!isPinned && messages.length > 0 && ( - // `bottom-32` matches the `pb-32` reserve above, so the pill lands in the - // gap between the last message and the floating composer. + {!isPinned && hasMessages && ( + // Icon-only, centred over the composer: the control means "the end of + // this transcript", not a global action. The label carries the meaning a + // text pill would have spent space on. )}
diff --git a/src/renderer/src/components/notebook/chat/stickToBottom.ts b/src/renderer/src/components/notebook/chat/stickToBottom.ts new file mode 100644 index 0000000..1e497d5 --- /dev/null +++ b/src/renderer/src/components/notebook/chat/stickToBottom.ts @@ -0,0 +1,65 @@ +/** + * The arithmetic behind the transcript's follow-the-answer behaviour. + * + * Kept out of the component for the same reason `panelGeometry` is: the numbers + * decide whether a streaming answer drags the reader along, and the only way to + * exercise them by hand is to scroll a mouse while a model talks. The rules are + * pure, so they can be asserted directly. + */ + +/** How close to the bottom still counts as "following the answer". */ +export const PINNED_THRESHOLD = 48 + +/** + * The viewport's distance from the true bottom is `scrollHeight - scrollTop - + * clientHeight`, but sub-pixel layout and font metrics can make that a small + * negative number at the end. Clamp it: a negative distance is "at the bottom", + * never "past it". + */ +export interface ScrollMetrics { + scrollHeight: number + scrollTop: number + clientHeight: number +} + +/** Pixels between the current position and the true bottom; never negative. */ +export function distanceToBottom(metrics: ScrollMetrics): number { + return Math.max(0, metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight) +} + +/** + * Whether the reader is close enough to the bottom to keep following. Compared + * against a distance rather than `scrollTop + clientHeight === scrollHeight`: + * that equality is false under fractional pixels and reflows even when the view + * is visibly at the end. + */ +export function isPinnedToBottom(metrics: ScrollMetrics, threshold = PINNED_THRESHOLD): boolean { + return distanceToBottom(metrics) <= threshold +} + +/** + * The composer floats over the transcript, so the transcript reserves this much + * space below its last message. `ProcessPanel` measures the real composer and + * writes the value here; the fallbacks apply before the first measurement and + * anywhere `MessageList` is rendered without one. + */ +export const COMPOSER_RESERVE_VAR = '--composer-reserve' + +/** Gap between the last message and the top of the floating composer. */ +export const COMPOSER_GAP = 24 + +/** Reserve used until the composer reports its height. */ +export const COMPOSER_RESERVE_FALLBACK = 152 + +/** Gap between the back-to-bottom button's lower edge and the composer's top. */ +export const BACK_TO_BOTTOM_GAP = 8 + +/** + * Inline `bottom` for the back-to-bottom control. The reserve runs from the + * viewport bottom to the composer's top plus `COMPOSER_GAP`, so the button sits + * `BACK_TO_BOTTOM_GAP` above the composer. + */ +export const BACK_TO_BOTTOM_BOTTOM = `calc(var(${COMPOSER_RESERVE_VAR}, ${COMPOSER_RESERVE_FALLBACK}px) - ${COMPOSER_GAP + BACK_TO_BOTTOM_GAP}px)` + +/** Inline `padding-bottom` reserving the composer's space in the transcript. */ +export const COMPOSER_RESERVE = `var(${COMPOSER_RESERVE_VAR}, ${COMPOSER_RESERVE_FALLBACK}px)` diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json index 607b48e..2d98609 100644 --- a/src/renderer/src/locales/en-US/ui.json +++ b/src/renderer/src/locales/en-US/ui.json @@ -1,6 +1,6 @@ { "home": "Home", - "jumpToLatest": "Jump to latest", + "jumpToLatest": "Back to bottom", "openFile": "Open File", "newChat": "Start a new conversation", "noMessages": "Type below to begin", diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json index 89e74ff..656a6ed 100644 --- a/src/renderer/src/locales/zh-CN/ui.json +++ b/src/renderer/src/locales/zh-CN/ui.json @@ -2,7 +2,7 @@ "newChat": "开始新对话", "noMessages": "在下方输入框中输入消息开始聊天", "home": "首页", - "jumpToLatest": "回到最新", + "jumpToLatest": "回到底部", "closeTab": "关闭标签", "settings": "设置", "myNotebooks": "我的笔记本", diff --git a/test/stickToBottom.test.ts b/test/stickToBottom.test.ts new file mode 100644 index 0000000..fa21200 --- /dev/null +++ b/test/stickToBottom.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + BACK_TO_BOTTOM_BOTTOM, + COMPOSER_GAP, + COMPOSER_RESERVE, + COMPOSER_RESERVE_FALLBACK, + COMPOSER_RESERVE_VAR, + PINNED_THRESHOLD, + distanceToBottom, + isPinnedToBottom +} from '../src/renderer/src/components/notebook/chat/stickToBottom.ts' + +/** + * The follow state is what decides whether a streaming answer drags the reader + * along. Getting the distance wrong in either direction is visible: too strict + * and the follow silently stops working, too loose and scrolling up to re-read + * is impossible. These assertions pin the arithmetic, not the DOM. + */ + +test('distance to the bottom is the space below the viewport', () => { + assert.equal(distanceToBottom({ scrollHeight: 1000, scrollTop: 400, clientHeight: 300 }), 300) +}) + +test('a viewport resting exactly at the bottom has zero distance', () => { + assert.equal(distanceToBottom({ scrollHeight: 1000, scrollTop: 700, clientHeight: 300 }), 0) +}) + +test('fractional pixels past the end clamp to zero, never negative', () => { + // Font metrics and sub-pixel layout routinely make this sum slightly negative + // at the end. A negative distance must still read as "at the bottom". + const distance = distanceToBottom({ scrollHeight: 1000.4, scrollTop: 700.6, clientHeight: 300 }) + assert.ok(distance >= 0) +}) + +test('height yet to be laid out does not produce a distance', () => { + assert.equal(distanceToBottom({ scrollHeight: 0, scrollTop: 0, clientHeight: 0 }), 0) +}) + +test('within the threshold counts as pinned', () => { + // 40px from the end, under the 48px threshold. + assert.equal(isPinnedToBottom({ scrollHeight: 1000, scrollTop: 660, clientHeight: 300 }), true) +}) + +test('exactly the threshold still counts as pinned', () => { + assert.equal( + isPinnedToBottom({ scrollHeight: 1000, scrollTop: 652, clientHeight: 300 }), + true, + 'a boundary comparison must be inclusive, or the follow flickers at the edge' + ) +}) + +test('beyond the threshold is not pinned', () => { + assert.equal(isPinnedToBottom({ scrollHeight: 1000, scrollTop: 600, clientHeight: 300 }), false) +}) + +test('the threshold is overridable', () => { + const metrics = { scrollHeight: 1000, scrollTop: 600, clientHeight: 300 } + assert.equal(isPinnedToBottom(metrics, 100), true) + assert.equal(isPinnedToBottom(metrics, 99), false) +}) + +test('the default threshold is the documented 48px', () => { + assert.equal(PINNED_THRESHOLD, 48) +}) + +test('the reserve reads the composer variable with a usable fallback', () => { + assert.equal(COMPOSER_RESERVE, `var(${COMPOSER_RESERVE_VAR}, ${COMPOSER_RESERVE_FALLBACK}px)`) +}) + +test('the back-to-bottom control sits above the composer, under the last message', () => { + // Reserve runs from the viewport bottom to COMPOSER_GAP above the composer; + // the button's lower edge is another BACK_TO_BOTTOM_GAP above the composer. + const reserve = 200 + const buttonBottom = reserve - (COMPOSER_GAP + 8) + // Button bottom is inside the reserve, nowhere near the viewport bottom. + assert.ok(buttonBottom > 0 && buttonBottom < reserve) + assert.equal( + BACK_TO_BOTTOM_BOTTOM, + `calc(var(${COMPOSER_RESERVE_VAR}, ${COMPOSER_RESERVE_FALLBACK}px) - ${COMPOSER_GAP + 8}px)` + ) +})