From b842c34fcac32264b80e269f5c2666d629c4c684 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 14:33:02 +0800 Subject: [PATCH] feat(ui): add scroll-to-latest button in assistant feed Track the assistant feed scroll position and footer height, and show a floating button to jump back to the newest message when scrolled up. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/i18n/locales/en-US.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../src/ui/pages/CanvasWorkspace/index.tsx | 138 ++++++++++++++---- frontend/src/ui/styles.css | 39 +++++ 4 files changed, 151 insertions(+), 28 deletions(-) diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 1483416..e7ed943 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -117,6 +117,7 @@ "conversation": "Chat", "newChat": "New chat", "conversationHistory": "History", + "scrollToLatest": "Scroll to latest", "searchConversationPlaceholder": "Search conversations", "deleteConversation": "Delete conversation", "noConversationHistory": "No history yet", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 0cc16aa..85e6045 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -117,6 +117,7 @@ "conversation": "对话", "newChat": "新对话", "conversationHistory": "历史对话", + "scrollToLatest": "回到底部", "searchConversationPlaceholder": "请输入搜索关键词", "deleteConversation": "删除对话", "noConversationHistory": "暂无历史对话", diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index 2ba1061..6255e67 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react"; +import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { openHome } from "@/application/route"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; @@ -550,6 +550,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const containerRef = useRef(null); const canvasImageInputRef = useRef(null); const agentComposerRef = useRef(null); + const assistantFeedRef = useRef(null); + const assistantFooterRef = useRef(null); + const autoScrollAgentFeedRef = useRef(false); const referenceImagesRef = useRef([]); const annotationsRef = useRef([]); const imageGeneratorThreadIdsRef = useRef>(new Set()); @@ -572,6 +575,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const [agentHistoryOpen, setAgentHistoryOpen] = useState(false); const [agentHistorySearch, setAgentHistorySearch] = useState(""); const [selectedAgentThreadId, setSelectedAgentThreadId] = useState(null); + const [assistantFooterHeight, setAssistantFooterHeight] = useState(0); + const [showAssistantScrollButton, setShowAssistantScrollButton] = useState(false); const [referenceImages, setReferenceImages] = useState([]); const [annotations, setAnnotations] = useState([]); const [annotationPreview, setAnnotationPreview] = useState(null); @@ -650,6 +655,29 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode)); const mockupPreparing = Boolean(mockupPendingTargetId); const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing; + const updateAssistantScrollButton = useCallback(() => { + const feed = assistantFeedRef.current; + if (!feed || feed.scrollHeight <= feed.clientHeight) { + setShowAssistantScrollButton(false); + return; + } + const distanceToEnd = feed.scrollHeight - feed.scrollTop - feed.clientHeight; + const shouldShow = distanceToEnd > 24; + setShowAssistantScrollButton((current) => (current === shouldShow ? current : shouldShow)); + }, []); + const scrollAssistantFeedToEnd = useCallback((behavior: ScrollBehavior = "auto") => { + window.requestAnimationFrame(() => { + const feed = assistantFeedRef.current; + if (!feed) return; + if (behavior === "auto") { + feed.scrollTop = feed.scrollHeight; + updateAssistantScrollButton(); + return; + } + feed.scrollTo({ top: feed.scrollHeight, behavior }); + window.setTimeout(updateAssistantScrollButton, 220); + }); + }, [updateAssistantScrollButton]); useEffect(() => { window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false"); @@ -754,12 +782,47 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { } }, [agentGenerating]); + useEffect(() => { + if (!assistantPanelOpen || !autoScrollAgentFeedRef.current) return; + scrollAssistantFeedToEnd("auto"); + if (agentTimelineActive) return; + const timer = window.setTimeout(() => { + autoScrollAgentFeedRef.current = false; + }, 250); + return () => window.clearTimeout(timer); + }, [agentTimelineActive, assistantPanelOpen, messages, scrollAssistantFeedToEnd]); + + useEffect(() => { + if (!assistantPanelOpen) { + setShowAssistantScrollButton(false); + return; + } + const frame = window.requestAnimationFrame(updateAssistantScrollButton); + return () => window.cancelAnimationFrame(frame); + }, [agentTimelineActive, assistantPanelOpen, messages, updateAssistantScrollButton]); + + useEffect(() => { + if (!assistantPanelOpen) return; + const footer = assistantFooterRef.current; + if (!footer) return; + const updateFooterHeight = () => { + setAssistantFooterHeight(Math.ceil(footer.getBoundingClientRect().height)); + updateAssistantScrollButton(); + }; + updateFooterHeight(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(updateFooterHeight); + observer.observe(footer); + return () => observer.disconnect(); + }, [assistantPanelOpen, error, updateAssistantScrollButton]); + useEffect(() => { selectedAgentThreadIdRef.current = null; setSelectedAgentThreadId(null); setAgentThreads([]); setAgentHistorySearch(""); setAgentHistoryOpen(false); + autoScrollAgentFeedRef.current = false; }, [projectId]); useEffect(() => { @@ -2435,6 +2498,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { }; const beginNewAgentConversation = () => { + autoScrollAgentFeedRef.current = false; selectedAgentThreadIdRef.current = newAgentConversationId; setSelectedAgentThreadId(newAgentConversationId); setAgentHistoryOpen(false); @@ -2446,6 +2510,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { }; const selectAgentThread = (threadId: string) => { + autoScrollAgentFeedRef.current = false; selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentHistoryOpen(false); @@ -2500,6 +2565,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentGenerating(true); + autoScrollAgentFeedRef.current = true; } setError(""); if (source === "image-generator") { @@ -2551,6 +2617,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const stopAgentTask = async () => { if (!project) return; const threadId = activeAgentThreadIdRef.current || project.lastThreadId; + autoScrollAgentFeedRef.current = false; stopGenerationStream("agent"); activeAgentThreadIdRef.current = null; setAgentGenerating(false); @@ -3858,37 +3925,52 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { -
+
- {error &&

{error}

} + {showAssistantScrollButton && ( +
0 ? assistantFooterHeight + 12 : 178 }}> + +
+ )} - } - onTextChange={setPrompt} - onModeChange={setComposerMode} - onSettingsChange={updateComposerImageSettings} - onModelChange={setSelectedCanvasModelId} - onWebSearchChange={setUseAgentWebSearch} - onUploadFile={attachImage} - onReferencesChange={syncReferenceImages} - onReferenceRemoved={handleReferenceRemoved} - onAnnotationsChange={syncAnnotations} - onAnnotationPreviewChange={setAnnotationPreview} - onSubmit={() => void generate("agent")} - onStop={() => void stopAgentTask()} - /> +
+ {error &&

{error}

} + + } + onTextChange={setPrompt} + onModeChange={setComposerMode} + onSettingsChange={updateComposerImageSettings} + onModelChange={setSelectedCanvasModelId} + onWebSearchChange={setUseAgentWebSearch} + onUploadFile={attachImage} + onReferencesChange={syncReferenceImages} + onReferenceRemoved={handleReferenceRemoved} + onAnnotationsChange={syncAnnotations} + onAnnotationPreviewChange={setAnnotationPreview} + onSubmit={() => void generate("agent")} + onStop={() => void stopAgentTask()} + /> +
)} diff --git a/frontend/src/ui/styles.css b/frontend/src/ui/styles.css index fff5d98..af71342 100644 --- a/frontend/src/ui/styles.css +++ b/frontend/src/ui/styles.css @@ -3602,6 +3602,11 @@ button:disabled { font-size: 13px; } +.assistant-footer-dock { + width: 100%; + flex: 0 0 auto; +} + .assistant-feed { position: relative; width: 100%; @@ -3637,6 +3642,40 @@ button:disabled { background: #dfe0e2; } +.assistant-scroll-to-end { + position: absolute; + inset-inline: 0; + z-index: 12; + display: flex; + justify-content: center; + pointer-events: none; +} + +.assistant-scroll-to-end button { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border: 1px solid #e1e3e8; + border-radius: 999px; + color: #373b42; + background: #fff; + box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); + pointer-events: auto; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.assistant-scroll-to-end button:hover { + border-color: #d4d8df; + background: #f8f9fa; + transform: translateY(-1px); +} + +.assistant-scroll-to-end button:active { + background: #eef0f3; + transform: translateY(0); +} + .generated-preview-card { display: grid; gap: 10px;