Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
47 changes: 40 additions & 7 deletions src/renderer/src/components/notebook/ProcessPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -58,6 +59,8 @@ function ProcessPanel({
const [isEditingTitle, setIsEditingTitle] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const messageListRef = useRef<MessageListHandle>(null)
const cardRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<HTMLDivElement>(null)

const currentNotebookId = currentSession?.notebookId
const isCurrentNotebookStreaming = currentNotebookId
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -229,7 +252,7 @@ function ProcessPanel({
}

return (
<Card className="relative flex h-full flex-col overflow-hidden">
<Card ref={cardRef} className="relative flex h-full flex-col overflow-hidden">
<PanelHeader
draggable
left={
Expand Down Expand Up @@ -302,16 +325,23 @@ function ProcessPanel({

{/* 对话消息区域 - 使用 absolute 定位占满剩余空间 */}
<div className="absolute top-14 bottom-0 left-0 right-0 overflow-hidden">
<MessageList ref={messageListRef} messages={messages} />
{/* Remount per session: every conversation opens at its own end, not at
the position the previous one happened to be left at. */}
<MessageList
key={currentSession?.id ?? 'no-session'}
ref={messageListRef}
messages={messages}
/>
</div>

{/* 底部渐变遮罩 - 独立于消息区域,避免堆叠上下文问题 */}
<div
// Height must match the `pb-32` reserve in MessageList: the fade only needs
// to cover the space the composer floats over. At h-48 (192px) it reached
// ~64px higher than the reserve and dimmed the last line of every answer.
className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none rounded-b-lg z-10"
// Height tracks the measured composer reserve (the CSS variable written
// above), so the fade covers exactly the space the composer floats over
// and never dims the last line of an answer.
className="absolute bottom-0 left-0 right-0 pointer-events-none rounded-b-lg z-10"
style={{
height: COMPOSER_RESERVE,
// A scroll fade, not decoration: it keeps the transcript readable as it
// passes under the floating composer. Uses the surface token rather than
// the legacy --card alias, and never a raw hsl().
Expand All @@ -321,7 +351,10 @@ function ProcessPanel({
/>

{/* 底部输入区域 - 绝对定位浮动在底部 */}
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-none shrink-0 z-20">
<div
ref={composerRef}
className="absolute bottom-0 left-0 right-0 p-4 pointer-events-none shrink-0 z-20"
>
<div className="relative bg-surface-overlay/95 backdrop-blur-md rounded-lg border border-border focus-within:ring-2 focus-within:ring-ring shadow-elevation pointer-events-auto select-none">
{/* 范围选择(#94):这次问题用哪些来源回答。放在输入框上方,跟在要提问的地方。 */}
{currentSession && (
Expand Down
114 changes: 88 additions & 26 deletions src/renderer/src/components/notebook/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<MessageListHandle, MessageListProps>(function MessageList(
{ messages },
ref
): ReactElement {
const viewportRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(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
Expand All @@ -47,6 +57,7 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(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
Expand All @@ -62,36 +73,67 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(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(() => {
Expand All @@ -101,6 +143,23 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(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.
Expand All @@ -112,7 +171,7 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(function Mes
</div>

<ScrollArea className="h-full" viewportRef={viewportRef}>
{messages.length === 0 ? (
{!hasMessages ? (
<div className="flex min-h-full items-center justify-center p-8">
<Empty className="border-none">
<EmptyHeader>
Expand All @@ -125,29 +184,32 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(function Mes
</Empty>
</div>
) : (
<div className="px-4 py-6 pb-32">
// Observed for height so the follow survives reflow without a message
// event. The reserved space keeps the last line above the composer.
<div ref={contentRef} className="px-4 py-6" style={{ paddingBottom: COMPOSER_RESERVE }}>
<div className="space-y-4">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
{/* 滚动锚点 */}
<div ref={bottomRef} />
</div>
</div>
)}
</ScrollArea>

{!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.
<Button
variant="outline"
size="sm"
size="icon"
onClick={pinToBottom}
className="absolute bottom-32 left-1/2 -translate-x-1/2 bg-surface-overlay shadow-elevation"
title={t('ui:jumpToLatest')}
aria-label={t('ui:jumpToLatest')}
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-surface-overlay shadow-elevation"
style={{ bottom: BACK_TO_BOTTOM_BOTTOM }}
>
<ArrowDown className="w-3 h-3" />
{t('ui:jumpToLatest')}
<ArrowDown />
</Button>
)}
</div>
Expand Down
Loading
Loading