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) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 14:33:02 +08:00
parent e9cfadfb90
commit b842c34fca
4 changed files with 151 additions and 28 deletions
+1
View File
@@ -117,6 +117,7 @@
"conversation": "Chat", "conversation": "Chat",
"newChat": "New chat", "newChat": "New chat",
"conversationHistory": "History", "conversationHistory": "History",
"scrollToLatest": "Scroll to latest",
"searchConversationPlaceholder": "Search conversations", "searchConversationPlaceholder": "Search conversations",
"deleteConversation": "Delete conversation", "deleteConversation": "Delete conversation",
"noConversationHistory": "No history yet", "noConversationHistory": "No history yet",
+1
View File
@@ -117,6 +117,7 @@
"conversation": "对话", "conversation": "对话",
"newChat": "新对话", "newChat": "新对话",
"conversationHistory": "历史对话", "conversationHistory": "历史对话",
"scrollToLatest": "回到底部",
"searchConversationPlaceholder": "请输入搜索关键词", "searchConversationPlaceholder": "请输入搜索关键词",
"deleteConversation": "删除对话", "deleteConversation": "删除对话",
"noConversationHistory": "暂无历史对话", "noConversationHistory": "暂无历史对话",
+110 -28
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { openHome } from "@/application/route"; import { openHome } from "@/application/route";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
@@ -550,6 +550,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasImageInputRef = useRef<HTMLInputElement | null>(null); const canvasImageInputRef = useRef<HTMLInputElement | null>(null);
const agentComposerRef = useRef<PromptComposerHandle | null>(null); const agentComposerRef = useRef<PromptComposerHandle | null>(null);
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
const assistantFooterRef = useRef<HTMLDivElement | null>(null);
const autoScrollAgentFeedRef = useRef(false);
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]); const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
const annotationsRef = useRef<CanvasAnnotation[]>([]); const annotationsRef = useRef<CanvasAnnotation[]>([]);
const imageGeneratorThreadIdsRef = useRef<Set<string>>(new Set()); const imageGeneratorThreadIdsRef = useRef<Set<string>>(new Set());
@@ -572,6 +575,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const [agentHistoryOpen, setAgentHistoryOpen] = useState(false); const [agentHistoryOpen, setAgentHistoryOpen] = useState(false);
const [agentHistorySearch, setAgentHistorySearch] = useState(""); const [agentHistorySearch, setAgentHistorySearch] = useState("");
const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null); const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null);
const [assistantFooterHeight, setAssistantFooterHeight] = useState(0);
const [showAssistantScrollButton, setShowAssistantScrollButton] = useState(false);
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]); const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
const [annotations, setAnnotations] = useState<CanvasAnnotation[]>([]); const [annotations, setAnnotations] = useState<CanvasAnnotation[]>([]);
const [annotationPreview, setAnnotationPreview] = useState<AnnotationPreviewRequest | null>(null); const [annotationPreview, setAnnotationPreview] = useState<AnnotationPreviewRequest | null>(null);
@@ -650,6 +655,29 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode)); const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode));
const mockupPreparing = Boolean(mockupPendingTargetId); const mockupPreparing = Boolean(mockupPendingTargetId);
const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing; 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(() => { useEffect(() => {
window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false"); window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false");
@@ -754,12 +782,47 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
} }
}, [agentGenerating]); }, [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(() => { useEffect(() => {
selectedAgentThreadIdRef.current = null; selectedAgentThreadIdRef.current = null;
setSelectedAgentThreadId(null); setSelectedAgentThreadId(null);
setAgentThreads([]); setAgentThreads([]);
setAgentHistorySearch(""); setAgentHistorySearch("");
setAgentHistoryOpen(false); setAgentHistoryOpen(false);
autoScrollAgentFeedRef.current = false;
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
@@ -2435,6 +2498,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const beginNewAgentConversation = () => { const beginNewAgentConversation = () => {
autoScrollAgentFeedRef.current = false;
selectedAgentThreadIdRef.current = newAgentConversationId; selectedAgentThreadIdRef.current = newAgentConversationId;
setSelectedAgentThreadId(newAgentConversationId); setSelectedAgentThreadId(newAgentConversationId);
setAgentHistoryOpen(false); setAgentHistoryOpen(false);
@@ -2446,6 +2510,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const selectAgentThread = (threadId: string) => { const selectAgentThread = (threadId: string) => {
autoScrollAgentFeedRef.current = false;
selectedAgentThreadIdRef.current = threadId; selectedAgentThreadIdRef.current = threadId;
setSelectedAgentThreadId(threadId); setSelectedAgentThreadId(threadId);
setAgentHistoryOpen(false); setAgentHistoryOpen(false);
@@ -2500,6 +2565,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
selectedAgentThreadIdRef.current = threadId; selectedAgentThreadIdRef.current = threadId;
setSelectedAgentThreadId(threadId); setSelectedAgentThreadId(threadId);
setAgentGenerating(true); setAgentGenerating(true);
autoScrollAgentFeedRef.current = true;
} }
setError(""); setError("");
if (source === "image-generator") { if (source === "image-generator") {
@@ -2551,6 +2617,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const stopAgentTask = async () => { const stopAgentTask = async () => {
if (!project) return; if (!project) return;
const threadId = activeAgentThreadIdRef.current || project.lastThreadId; const threadId = activeAgentThreadIdRef.current || project.lastThreadId;
autoScrollAgentFeedRef.current = false;
stopGenerationStream("agent"); stopGenerationStream("agent");
activeAgentThreadIdRef.current = null; activeAgentThreadIdRef.current = null;
setAgentGenerating(false); setAgentGenerating(false);
@@ -3858,37 +3925,52 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
</div> </div>
</div> </div>
<div className="assistant-feed"> <div className="assistant-feed" ref={assistantFeedRef} onScroll={updateAssistantScrollButton}>
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} /> <CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
</div> </div>
{error && <p className="panel-error">{error}</p>} {showAssistantScrollButton && (
<div className="assistant-scroll-to-end" style={{ bottom: assistantFooterHeight > 0 ? assistantFooterHeight + 12 : 178 }}>
<button
type="button"
aria-label={t("scrollToLatest")}
title={t("scrollToLatest")}
onClick={() => scrollAssistantFeedToEnd("smooth")}
>
<ArrowDown size={18} />
</button>
</div>
)}
<CanvasAgentComposer <div className="assistant-footer-dock" ref={assistantFooterRef}>
ref={agentComposerRef} {error && <p className="panel-error">{error}</p>}
text={prompt}
mode={composerMode} <CanvasAgentComposer
models={imageModels} ref={agentComposerRef}
selectedModel={selectedCanvasModel} text={prompt}
settings={composerImageSettings} mode={composerMode}
references={referenceImages} models={imageModels}
annotations={annotations} selectedModel={selectedCanvasModel}
webSearchEnabled={useAgentWebSearch} settings={composerImageSettings}
generating={agentGenerating} references={referenceImages}
previewSlot={<CanvasAnnotationPreview preview={annotationPreview} nodes={nodes} />} annotations={annotations}
onTextChange={setPrompt} webSearchEnabled={useAgentWebSearch}
onModeChange={setComposerMode} generating={agentGenerating}
onSettingsChange={updateComposerImageSettings} previewSlot={<CanvasAnnotationPreview preview={annotationPreview} nodes={nodes} />}
onModelChange={setSelectedCanvasModelId} onTextChange={setPrompt}
onWebSearchChange={setUseAgentWebSearch} onModeChange={setComposerMode}
onUploadFile={attachImage} onSettingsChange={updateComposerImageSettings}
onReferencesChange={syncReferenceImages} onModelChange={setSelectedCanvasModelId}
onReferenceRemoved={handleReferenceRemoved} onWebSearchChange={setUseAgentWebSearch}
onAnnotationsChange={syncAnnotations} onUploadFile={attachImage}
onAnnotationPreviewChange={setAnnotationPreview} onReferencesChange={syncReferenceImages}
onSubmit={() => void generate("agent")} onReferenceRemoved={handleReferenceRemoved}
onStop={() => void stopAgentTask()} onAnnotationsChange={syncAnnotations}
/> onAnnotationPreviewChange={setAnnotationPreview}
onSubmit={() => void generate("agent")}
onStop={() => void stopAgentTask()}
/>
</div>
</aside> </aside>
)} )}
</main> </main>
+39
View File
@@ -3602,6 +3602,11 @@ button:disabled {
font-size: 13px; font-size: 13px;
} }
.assistant-footer-dock {
width: 100%;
flex: 0 0 auto;
}
.assistant-feed { .assistant-feed {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -3637,6 +3642,40 @@ button:disabled {
background: #dfe0e2; 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 { .generated-preview-card {
display: grid; display: grid;
gap: 10px; gap: 10px;