From ed63e5a1d6b73fc12a0bdfd19797a62975b275f0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 24 Dec 2025 02:09:24 +0530 Subject: [PATCH 1/4] feat: add auto-scroll functionality for new user queries - Introduced NewQueryScrollHandler component to automatically scroll to the latest user message when a new query is submitted, enhancing user experience. - Updated ChatHeader component to remove unnecessary border for a cleaner design. - Adjusted ModelSelector styles for improved visual consistency and accessibility. --- .../components/assistant-ui/thread.tsx | 51 +++++++++++++++++++ .../components/new-chat/chat-header.tsx | 2 +- .../components/new-chat/model-selector.tsx | 16 +++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 33d6a0cad..8a7eca58e 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -262,6 +262,55 @@ const ThinkingStepsScrollHandler: FC = () => { 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(0); + const prevLastUserMsgIdRef = useRef(""); + + 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 = ({ messageThinkingSteps = new Map() }) => { return ( @@ -275,6 +324,8 @@ export const Thread: FC = ({ messageThinkingSteps = new Map() }) => turnAnchor="top" className="aui-thread-viewport relative flex flex-1 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 */} + {/* Auto-scroll handler for thinking steps - must be inside Viewport */} diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index ef1533e23..1e0259447 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -48,7 +48,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { return ( <> {/* Header Bar */} -
+
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 89390f957..2af476e3b 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -175,9 +175,10 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp role="combobox" aria-expanded={open} className={cn( - "h-9 gap-2 px-3 rounded-xl border border-border/50 bg-background/50 backdrop-blur-sm", - "hover:bg-muted/80 hover:border-border transition-all duration-200", + "h-9 gap-2 px-3 rounded-xl border border-border/30 bg-background/50 backdrop-blur-sm", + "hover:bg-muted/80 hover:border-border/30 transition-all duration-200", "text-sm font-medium text-foreground", + "focus-visible:ring-0 focus-visible:ring-offset-0", className )} > @@ -206,11 +207,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp - + {/* Switching overlay */} {isSwitching && (
@@ -221,8 +222,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
)} -
- +
0 && filteredUserConfigs.length > 0 && ( - + )} {/* User Configs Section */} @@ -362,7 +362,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp )} {/* Add New Config Button */} -
+
-
{children}
+
{children}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 00514facb..e18629b92 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -626,11 +626,11 @@ export default function NewChatPage() { -
- -
- -
+
+ } + />
); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 8a7eca58e..6e69296e5 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -8,7 +8,6 @@ import { ThreadPrimitive, useAssistantState, useMessage, - useThreadViewport, } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { @@ -69,6 +68,8 @@ import { cn } from "@/lib/utils"; */ interface ThreadProps { messageThinkingSteps?: Map; + /** Optional header component to render at the top of the viewport (sticky) */ + header?: React.ReactNode; } // Context to pass thinking steps to AssistantMessage @@ -212,122 +213,25 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea ); }; -/** - * 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(""); - - 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(0); - const prevLastUserMsgIdRef = useRef(""); - - 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 = ({ messageThinkingSteps = new Map() }) => { +export const Thread: FC = ({ messageThinkingSteps = new Map(), header }) => { return ( - {/* Auto-scroll handler for new queries - positions new user messages at the top */} - - {/* Auto-scroll handler for thinking steps - must be inside Viewport */} - + {/* Optional sticky header for model selector etc. */} + {header && ( +
+ {header} +
+ )} thread.isEmpty}> diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 1e0259447..34b2cc814 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -47,12 +47,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { return ( <> - {/* Header Bar */} -
- -
- - {/* Config Sidebar */} + Date: Tue, 23 Dec 2025 18:50:24 -0800 Subject: [PATCH 4/4] chore: linting --- surfsense_web/components/assistant-ui/thread.tsx | 6 +----- surfsense_web/components/new-chat/model-selector.tsx | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6e69296e5..9596f4fe0 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -227,11 +227,7 @@ export const Thread: FC = ({ messageThinkingSteps = new Map(), head 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" > {/* Optional sticky header for model selector etc. */} - {header && ( -
- {header} -
- )} + {header &&
{header}
} thread.isEmpty}> diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 2af476e3b..5dcf3bafa 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -211,7 +211,10 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp align="start" sideOffset={8} > - + {/* Switching overlay */} {isSwitching && (