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",
"newChat": "New chat",
"conversationHistory": "History",
"scrollToLatest": "Scroll to latest",
"searchConversationPlaceholder": "Search conversations",
"deleteConversation": "Delete conversation",
"noConversationHistory": "No history yet",
+1
View File
@@ -117,6 +117,7 @@
"conversation": "对话",
"newChat": "新对话",
"conversationHistory": "历史对话",
"scrollToLatest": "回到底部",
"searchConversationPlaceholder": "请输入搜索关键词",
"deleteConversation": "删除对话",
"noConversationHistory": "暂无历史对话",
+110 -28
View File
@@ -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<HTMLDivElement | null>(null);
const canvasImageInputRef = useRef<HTMLInputElement | 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 annotationsRef = useRef<CanvasAnnotation[]>([]);
const imageGeneratorThreadIdsRef = useRef<Set<string>>(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<string | null>(null);
const [assistantFooterHeight, setAssistantFooterHeight] = useState(0);
const [showAssistantScrollButton, setShowAssistantScrollButton] = useState(false);
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
const [annotations, setAnnotations] = useState<CanvasAnnotation[]>([]);
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 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 }) {
</div>
</div>
<div className="assistant-feed">
<div className="assistant-feed" ref={assistantFeedRef} onScroll={updateAssistantScrollButton}>
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
</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
ref={agentComposerRef}
text={prompt}
mode={composerMode}
models={imageModels}
selectedModel={selectedCanvasModel}
settings={composerImageSettings}
references={referenceImages}
annotations={annotations}
webSearchEnabled={useAgentWebSearch}
generating={agentGenerating}
previewSlot={<CanvasAnnotationPreview preview={annotationPreview} nodes={nodes} />}
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()}
/>
<div className="assistant-footer-dock" ref={assistantFooterRef}>
{error && <p className="panel-error">{error}</p>}
<CanvasAgentComposer
ref={agentComposerRef}
text={prompt}
mode={composerMode}
models={imageModels}
selectedModel={selectedCanvasModel}
settings={composerImageSettings}
references={referenceImages}
annotations={annotations}
webSearchEnabled={useAgentWebSearch}
generating={agentGenerating}
previewSlot={<CanvasAnnotationPreview preview={annotationPreview} nodes={nodes} />}
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()}
/>
</div>
</aside>
)}
</main>
+39
View File
@@ -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;