feat(chat): 实现多视图滚动位置保存与恢复功能
- 在 App 组件中新增 viewKey,用于标识当前视图 - 通过属性链传递 viewKey 至 MessageList 以区分不同滚动上下文 - MessageList 中新增滚动位置缓存机制,使用 Map 保存各视图的滚动状态 - 在视图切换时自动恢复对应滚动位置,提升用户体验 - 保持消息新增时的自动滚动行为,兼顾视图切换与消息更新场景
This commit is contained in:
parent
bf724b133c
commit
8684ff9549
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ----
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user