mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
refactor: fix scroll to last user query ux
- Updated DashboardClientLayout to improve child component overflow handling. - Refactored NewChatPage to streamline the layout and integrate ChatHeader directly within the Thread component. - Added optional header prop to Thread component for better customization. - Cleaned up ChatHeader by removing unnecessary wrapper for improved design.
This commit is contained in:
parent
0a458dde2f
commit
40e982d541
4 changed files with 18 additions and 119 deletions
|
|
@ -265,7 +265,7 @@ export function DashboardClientLayout({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
|
<div className="flex-1 overflow-hidden">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
|
||||||
|
|
@ -626,11 +626,11 @@ export default function NewChatPage() {
|
||||||
<LinkPreviewToolUI />
|
<LinkPreviewToolUI />
|
||||||
<DisplayImageToolUI />
|
<DisplayImageToolUI />
|
||||||
<ScrapeWebpageToolUI />
|
<ScrapeWebpageToolUI />
|
||||||
<div className="flex flex-col h-[calc(100vh-64px)] max-h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||||
<ChatHeader searchSpaceId={searchSpaceId} />
|
<Thread
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
messageThinkingSteps={messageThinkingSteps}
|
||||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
useMessage,
|
useMessage,
|
||||||
useThreadViewport,
|
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
|
|
@ -69,6 +68,8 @@ import { cn } from "@/lib/utils";
|
||||||
*/
|
*/
|
||||||
interface ThreadProps {
|
interface ThreadProps {
|
||||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||||
|
/** Optional header component to render at the top of the viewport (sticky) */
|
||||||
|
header?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context to pass thinking steps to AssistantMessage
|
// Context to pass thinking steps to AssistantMessage
|
||||||
|
|
@ -212,122 +213,25 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
||||||
* Component that handles auto-scroll when thinking steps update.
|
|
||||||
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
|
||||||
* ensuring the user always sees the latest content during streaming.
|
|
||||||
*/
|
|
||||||
const ThinkingStepsScrollHandler: FC = () => {
|
|
||||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
|
||||||
const viewport = useThreadViewport();
|
|
||||||
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
||||||
// Track the serialized state to detect any changes
|
|
||||||
const prevStateRef = useRef<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only act during streaming
|
|
||||||
if (!isRunning) {
|
|
||||||
prevStateRef.current = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize the thinking steps state to detect any changes
|
|
||||||
// This catches new steps, status changes, and item additions
|
|
||||||
let stateString = "";
|
|
||||||
thinkingStepsMap.forEach((steps, msgId) => {
|
|
||||||
steps.forEach((step) => {
|
|
||||||
stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// If state changed at all during streaming, scroll
|
|
||||||
if (stateString !== prevStateRef.current && stateString !== "") {
|
|
||||||
prevStateRef.current = stateString;
|
|
||||||
|
|
||||||
// Multiple attempts to ensure scroll happens after DOM updates
|
|
||||||
const scrollAttempt = () => {
|
|
||||||
try {
|
|
||||||
viewport.scrollToBottom();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors - viewport might not be ready
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delayed attempts to handle async DOM updates
|
|
||||||
requestAnimationFrame(scrollAttempt);
|
|
||||||
setTimeout(scrollAttempt, 100);
|
|
||||||
}
|
|
||||||
}, [thinkingStepsMap, viewport, isRunning]);
|
|
||||||
|
|
||||||
return null; // This component doesn't render anything
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component that handles auto-scroll when a new user query is submitted.
|
|
||||||
* Scrolls to position the new user message at the top of the viewport,
|
|
||||||
* similar to Cursor's behavior where the current query stays at the top.
|
|
||||||
*/
|
|
||||||
const NewQueryScrollHandler: FC = () => {
|
|
||||||
const messages = useAssistantState(({ thread }) => thread.messages);
|
|
||||||
const prevMessageCountRef = useRef<number>(0);
|
|
||||||
const prevLastUserMsgIdRef = useRef<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentCount = messages.length;
|
|
||||||
|
|
||||||
// Find the last user message
|
|
||||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
|
||||||
const lastUserMsgId = lastUserMessage?.id || "";
|
|
||||||
|
|
||||||
// Check if a new user message was added (not on initial load)
|
|
||||||
if (
|
|
||||||
prevMessageCountRef.current > 0 && // Not initial load
|
|
||||||
currentCount > prevMessageCountRef.current &&
|
|
||||||
lastUserMsgId !== prevLastUserMsgIdRef.current &&
|
|
||||||
lastUserMsgId
|
|
||||||
) {
|
|
||||||
// New user message added - scroll to make it visible at the top
|
|
||||||
// Use multiple attempts to ensure the DOM has updated
|
|
||||||
const scrollToNewQuery = () => {
|
|
||||||
// Find the last user message element
|
|
||||||
const userMessages = document.querySelectorAll('[data-role="user"]');
|
|
||||||
const lastUserMsgElement = userMessages[userMessages.length - 1];
|
|
||||||
if (lastUserMsgElement) {
|
|
||||||
// Scroll so the user message is at the top of the viewport
|
|
||||||
lastUserMsgElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delayed attempts to handle async DOM updates
|
|
||||||
requestAnimationFrame(scrollToNewQuery);
|
|
||||||
setTimeout(scrollToNewQuery, 50);
|
|
||||||
setTimeout(scrollToNewQuery, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
prevMessageCountRef.current = currentCount;
|
|
||||||
prevLastUserMsgIdRef.current = lastUserMsgId;
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
return null; // This component doesn't render anything
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
|
||||||
return (
|
return (
|
||||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||||
<ThreadPrimitive.Root
|
<ThreadPrimitive.Root
|
||||||
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
|
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||||
style={{
|
style={{
|
||||||
["--thread-max-width" as string]: "44rem",
|
["--thread-max-width" as string]: "44rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||||
>
|
>
|
||||||
{/* Auto-scroll handler for new queries - positions new user messages at the top */}
|
{/* Optional sticky header for model selector etc. */}
|
||||||
<NewQueryScrollHandler />
|
{header && (
|
||||||
{/* Auto-scroll handler for thinking steps - must be inside Viewport */}
|
<div className="sticky top-0 z-10 mb-4">
|
||||||
<ThinkingStepsScrollHandler />
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header Bar */}
|
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-background/80 backdrop-blur-sm">
|
|
||||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config Sidebar */}
|
|
||||||
<ModelConfigSidebar
|
<ModelConfigSidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onOpenChange={handleSidebarClose}
|
onOpenChange={handleSidebarClose}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue