feat(chat): 实现多视图滚动位置保存与恢复功能

- 在 App 组件中新增 viewKey,用于标识当前视图
- 通过属性链传递 viewKey 至 MessageList 以区分不同滚动上下文
- MessageList 中新增滚动位置缓存机制,使用 Map 保存各视图的滚动状态
- 在视图切换时自动恢复对应滚动位置,提升用户体验
- 保持消息新增时的自动滚动行为,兼顾视图切换与消息更新场景
This commit is contained in:
oudecheng 2026-06-18 15:02:25 +08:00
parent bf724b133c
commit 8684ff9549
3 changed files with 51 additions and 4 deletions

View File

@ -474,6 +474,13 @@ function App() {
return result return result
}, [messages]) }, [messages])
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置
const viewKey = useMemo(() => {
if (schedulerView) return `scheduler:${schedulerView.jobId}`
if (subAgentView) return `subagent:${subAgentView.taskId}`
return 'main'
}, [schedulerView, subAgentView])
return ( return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden"> <div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
{/* Header */} {/* Header */}
@ -686,6 +693,7 @@ function App() {
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey}
todoPanel={ todoPanel={
<TodoPanel <TodoPanel
todos={todos} todos={todos}

View File

@ -13,6 +13,8 @@ interface ChatContainerProps {
showThinking?: boolean showThinking?: boolean
/** 浮动待办面板,绝对定位在消息区域上方 */ /** 浮动待办面板,绝对定位在消息区域上方 */
todoPanel?: React.ReactNode todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string
} }
export function ChatContainer({ export function ChatContainer({
@ -25,11 +27,12 @@ export function ChatContainer({
onStop, onStop,
showThinking = true, showThinking = true,
todoPanel, todoPanel,
viewKey,
}: ChatContainerProps) { }: ChatContainerProps) {
return ( return (
<div className="flex h-full flex-col relative"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} /> <MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} />
{todoPanel} {todoPanel}
</div> </div>
<MessageInput <MessageInput

View File

@ -7,14 +7,22 @@ interface MessageListProps {
messages: ChatMessage[] messages: ChatMessage[]
onNavigateToSubAgent?: (taskId: string, description: string) => void onNavigateToSubAgent?: (taskId: string, description: string) => void
showThinking?: boolean showThinking?: boolean
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string
} }
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true }: MessageListProps) { export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) const isAtBottomRef = useRef(true)
const prevShowBottomRef = useRef(false) const prevShowBottomRef = useRef(false)
const prevShowTopRef = useRef(false) const prevShowTopRef = useRef(false)
const prevViewKeyRef = useRef(viewKey)
const viewKeyRef = useRef(viewKey)
viewKeyRef.current = viewKey
// Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map())
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [showScrollToTop, setShowScrollToTop] = useState(false) const [showScrollToTop, setShowScrollToTop] = useState(false)
@ -42,6 +50,12 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120 const nearBottom = distanceFromBottom < 120
// Save scroll position for current view
const key = viewKeyRef.current
if (key) {
scrollPositionsRef.current.set(key, el.scrollTop)
}
isAtBottomRef.current = nearBottom isAtBottomRef.current = nearBottom
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8 const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
@ -61,14 +75,36 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
} }
}, [hasNewMessage]) }, [hasNewMessage])
// ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ---- // ---- auto-scroll: handle view switches and message updates ----
useLayoutEffect(() => { useLayoutEffect(() => {
const prevKey = prevViewKeyRef.current
const viewChanged = prevKey !== viewKey
prevViewKeyRef.current = viewKey
if (messages.length === 0) { if (messages.length === 0) {
isAtBottomRef.current = true isAtBottomRef.current = true
return return
} }
if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position
const key = viewKey ?? ''
const savedPos = scrollPositionsRef.current.get(key)
if (savedPos !== undefined && containerRef.current) {
containerRef.current.scrollTop = savedPos
const el = containerRef.current
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
isAtBottomRef.current = distanceFromBottom < 120
return
}
// First time viewing this view: scroll to bottom
isAtBottomRef.current = true
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
return
}
// Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
@ -76,7 +112,7 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
} else { } else {
setHasNewMessage(true) setHasNewMessage(true)
} }
}, [messages]) }, [messages, viewKey])
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----