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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user