diff --git a/web/src/App.tsx b/web/src/App.tsx index 67a7e73..91ef78e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -474,6 +474,13 @@ function App() { return result }, [messages]) + // 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置 + const viewKey = useMemo(() => { + if (schedulerView) return `scheduler:${schedulerView.jobId}` + if (subAgentView) return `subagent:${subAgentView.taskId}` + return 'main' + }, [schedulerView, subAgentView]) + return (
{/* Header */} @@ -686,6 +693,7 @@ function App() { onNavigateToSubAgent={handleNavigateToSubAgent} onStop={handleStopExecution} showThinking={showThinking} + viewKey={viewKey} todoPanel={
- + {todoPanel}
void showThinking?: boolean + /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ + viewKey?: string } -export function MessageList({ messages, onNavigateToSubAgent, showThinking = true }: MessageListProps) { +export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) { const bottomRef = useRef(null) const containerRef = useRef(null) const isAtBottomRef = useRef(true) const prevShowBottomRef = 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>(new Map()) const [showScrollToBottom, setShowScrollToBottom] = 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 nearBottom = distanceFromBottom < 120 + // Save scroll position for current view + const key = viewKeyRef.current + if (key) { + scrollPositionsRef.current.set(key, el.scrollTop) + } + isAtBottomRef.current = nearBottom const shouldShowTop = el.scrollTop > el.clientHeight * 0.8 @@ -61,14 +75,36 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru } }, [hasNewMessage]) - // ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ---- + // ---- auto-scroll: handle view switches and message updates ---- useLayoutEffect(() => { + const prevKey = prevViewKeyRef.current + const viewChanged = prevKey !== viewKey + prevViewKeyRef.current = viewKey + if (messages.length === 0) { isAtBottomRef.current = true 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] if (lastMessage.role === 'user' || isAtBottomRef.current) { @@ -76,7 +112,7 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru } else { setHasNewMessage(true) } - }, [messages]) + }, [messages, viewKey]) // ---- mount: always scroll to bottom if messages already loaded ----