|
|
@@ -1,11 +1,16 @@
|
|
|
// ChatView: the default conversation view — message flow with user bubbles,
|
|
|
// assistant narration, tool summary rows grouped into step runs, pending
|
|
|
-// cards, paging, bottom-follow, and the session stats line under the flow
|
|
|
-// (chrome dissolved into the view: the footer is part of what a chat view
|
|
|
-// IS, not registration metadata). Pure component registered directly; its
|
|
|
-// registration declares the keyed 'conversation.chat.toolview' hole, so tool
|
|
|
-// rows render through the props renderSlot share (entryKey = tool name,
|
|
|
-// GenericToolCard as the render-site fallback).
|
|
|
+// cards, paging, and bottom-follow. Session stats live on
|
|
|
+// 'conversation.composer.dock' (sticky with the composer). Pure component
|
|
|
+// registered directly; its registration declares the keyed
|
|
|
+// 'conversation.chat.toolview' hole, so tool rows render through the props
|
|
|
+// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
|
|
+// fallback).
|
|
|
+//
|
|
|
+// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
|
|
+// column), that host is the scrollport and this view is flow content; when
|
|
|
+// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
|
|
+// prepend anchoring always target the resolved scrollport.
|
|
|
//
|
|
|
// Render economics (architecture RFC performance model): the list parent
|
|
|
// subscribes to snapshot segments that do NOT change per streaming chunk
|
|
|
@@ -17,7 +22,7 @@
|
|
|
// memoized rows never churns them.
|
|
|
|
|
|
import {
|
|
|
- memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
|
|
+ memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
|
|
} from 'react'
|
|
|
import type {
|
|
|
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
|
|
@@ -30,11 +35,15 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
|
|
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
|
|
import { GenericToolCard } from './GenericToolCard.tsx'
|
|
|
import { MessageItem } from './MessageItem.tsx'
|
|
|
-import { StatsLine } from './StatsLine.tsx'
|
|
|
import css from './ChatView.module.css'
|
|
|
|
|
|
const FOLLOW_THRESHOLD = 24
|
|
|
|
|
|
+/** Active column host when present; otherwise the view-local scroller. */
|
|
|
+function scrollerOf(from: HTMLElement): HTMLElement {
|
|
|
+ return (from.closest('[data-conversation-scroll]')) ?? from
|
|
|
+}
|
|
|
+
|
|
|
type OpenFile = (path: string) => void
|
|
|
|
|
|
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
|
|
@@ -244,26 +253,34 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|
|
const firstSeqRef = useRef<number | null>(null)
|
|
|
const openedRef = useRef(false)
|
|
|
const lastKeyRef = useRef<string | null>(null)
|
|
|
+ /** Flow tip signature — follow-scroll only when this moves, never on a
|
|
|
+ * scroll-driven at-bottom chrome re-render (that was snapping inertial
|
|
|
+ * scrolls the rest of the way to the floor). */
|
|
|
+ const followSigRef = useRef<string | null>(null)
|
|
|
|
|
|
const firstSeq = nodes[0]?.seq ?? null
|
|
|
const lastItem = items[items.length - 1]
|
|
|
+ const lastKey = lastItem?.key ?? null
|
|
|
+ const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
|
|
|
|
|
- const toBottom = (el: HTMLDivElement): void => {
|
|
|
+ const toBottom = (el: HTMLElement): void => {
|
|
|
el.scrollTop = el.scrollHeight
|
|
|
atBottomRef.current = true
|
|
|
setAtBottom(true)
|
|
|
}
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
- const el = listRef.current
|
|
|
+ const local = listRef.current
|
|
|
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
|
|
- if (el === null) return
|
|
|
+ if (local === null) return
|
|
|
+ const el = scrollerOf(local)
|
|
|
// Open completed: jump to the bottom once.
|
|
|
if (openState === 'open' && !openedRef.current) {
|
|
|
openedRef.current = true
|
|
|
toBottom(el)
|
|
|
firstSeqRef.current = firstSeq
|
|
|
- lastKeyRef.current = lastItem?.key ?? null
|
|
|
+ lastKeyRef.current = lastKey
|
|
|
+ followSigRef.current = followSig
|
|
|
return
|
|
|
}
|
|
|
// Prepend (head seq decreased): compensate by the height delta.
|
|
|
@@ -272,42 +289,65 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|
|
anchorRef.current = null
|
|
|
firstSeqRef.current = firstSeq
|
|
|
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
|
|
- lastKeyRef.current = lastItem?.key ?? null
|
|
|
+ lastKeyRef.current = lastKey
|
|
|
+ followSigRef.current = followSig
|
|
|
return
|
|
|
}
|
|
|
firstSeqRef.current = firstSeq
|
|
|
// Own words must be visible: a new trailing user node force-scrolls
|
|
|
// (send lives in the composer, so arrival is detected here, not armed there).
|
|
|
- const lastKey = lastItem?.key ?? null
|
|
|
const appendedUser = lastKey !== lastKeyRef.current
|
|
|
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
|
|
+ const tipMoved = followSigRef.current !== followSig
|
|
|
lastKeyRef.current = lastKey
|
|
|
- if (appendedUser || atBottomRef.current) toBottom(el)
|
|
|
+ followSigRef.current = followSig
|
|
|
+ // Follow new flow content while pinned; do NOT re-pin on every render
|
|
|
+ // merely because atBottomRef is true (scroll threshold → setState → snap).
|
|
|
+ if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
|
|
})
|
|
|
|
|
|
- const onScroll = (): void => {
|
|
|
- const el = listRef.current
|
|
|
- /* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
|
|
|
- if (el === null) return
|
|
|
+ const onScrollRef = useRef(() => {})
|
|
|
+ onScrollRef.current = () => {
|
|
|
+ const local = listRef.current
|
|
|
+ /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
|
|
+ if (local === null) return
|
|
|
+ const el = scrollerOf(local)
|
|
|
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
|
|
atBottomRef.current = isAtBottom
|
|
|
setAtBottom(isAtBottom)
|
|
|
}
|
|
|
|
|
|
+ // Bind scroll to the resolved scrollport (host or local) once per mount.
|
|
|
+ useEffect(() => {
|
|
|
+ const local = listRef.current
|
|
|
+ /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
|
|
+ if (local === null) return
|
|
|
+ const el = scrollerOf(local)
|
|
|
+ const onScroll = (): void => { onScrollRef.current() }
|
|
|
+ el.addEventListener('scroll', onScroll, { passive: true })
|
|
|
+ return () => { el.removeEventListener('scroll', onScroll) }
|
|
|
+ }, [])
|
|
|
+
|
|
|
// Follow streaming growth the parent never re-renders for (stable ref).
|
|
|
// The ref starts null and is assigned every render, so the placeholder
|
|
|
// initializer a function initial value would need never exists.
|
|
|
const followRef = useRef<(() => void) | null>(null)
|
|
|
followRef.current = () => {
|
|
|
- const el = listRef.current
|
|
|
- if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
|
|
|
+ const local = listRef.current
|
|
|
+ if (local !== null && atBottomRef.current) {
|
|
|
+ const el = scrollerOf(local)
|
|
|
+ el.scrollTop = el.scrollHeight
|
|
|
+ }
|
|
|
}
|
|
|
const onGrow = useRef(() => followRef.current?.()).current
|
|
|
|
|
|
const loadOlderAnchored = (): void => {
|
|
|
- const el = listRef.current
|
|
|
+ const local = listRef.current
|
|
|
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
|
|
- if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
|
|
+ if (local !== null) {
|
|
|
+ const el = scrollerOf(local)
|
|
|
+ anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
|
|
+ }
|
|
|
loadOlder()
|
|
|
}
|
|
|
|
|
|
@@ -350,7 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|
|
|
|
|
return (
|
|
|
<div className={css.root}>
|
|
|
- <div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
|
|
+ <div ref={listRef} className={css.scroll}>
|
|
|
<div className={css.column}>
|
|
|
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
|
|
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
|
|
@@ -388,22 +428,23 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|
|
wait, tool execution, streaming) so it never flickers per step. */}
|
|
|
{running && <TurnDots />}
|
|
|
</div>
|
|
|
+ {!atBottom && (
|
|
|
+ <div className={css.toBottomSlot}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.toBottom}
|
|
|
+ aria-label="回到底部"
|
|
|
+ onClick={() => {
|
|
|
+ const local = listRef.current
|
|
|
+ /* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
|
|
+ if (local !== null) toBottom(scrollerOf(local))
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconChevronDownOutline14 />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- <StatsLine useSession={useSession} />
|
|
|
- {!atBottom && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.toBottom}
|
|
|
- aria-label="回到底部"
|
|
|
- onClick={() => {
|
|
|
- const el = listRef.current
|
|
|
- /* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
|
|
- if (el !== null) toBottom(el)
|
|
|
- }}
|
|
|
- >
|
|
|
- <IconChevronDownOutline14 />
|
|
|
- </button>
|
|
|
- )}
|
|
|
</div>
|
|
|
)
|
|
|
}
|