diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7ef34cd1..f3bfed02 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -20,7 +20,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, @@ -62,6 +62,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type ToolCall, @@ -841,6 +842,7 @@ function App() { const chatDraftsRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) + const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) const activeChatTabIdRef = useRef(activeChatTabId) activeChatTabIdRef.current = activeChatTabId const setChatDraftForTab = useCallback((tabId: string, text: string) => { @@ -866,6 +868,18 @@ function App() { } }) }, []) + const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => { + setChatViewportAnchorByTab((prev) => { + const prevForTab = prev[tabId] + return { + ...prev, + [tabId]: { + messageId, + requestKey: (prevForTab?.requestKey ?? 0) + 1, + }, + } + }) + }, []) const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => { if (typeof document === 'undefined') return null const panel = document.querySelector( @@ -967,6 +981,22 @@ function App() { }) }, [chatTabs]) + useEffect(() => { + const tabIds = new Set(chatTabs.map((tab) => tab.id)) + setChatViewportAnchorByTab((prev) => { + let changed = false + const next: Record = {} + for (const [tabId, state] of Object.entries(prev)) { + if (tabIds.has(tabId)) { + next[tabId] = state + } else { + changed = true + } + } + return changed ? next : prev + }) + }, [chatTabs]) + // Workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') @@ -2095,6 +2125,7 @@ function App() { ) => { if (isProcessing) return + const submitTabId = activeChatTabIdRef.current const { text } = message const userMessage = text.trim() const hasAttachments = stagedAttachments.length > 0 @@ -2119,6 +2150,7 @@ function App() { attachments: displayAttachments, timestamp: Date.now(), }]) + setChatViewportAnchor(submitTabId, userMessageId) try { let currentRunId = runId @@ -2133,7 +2165,7 @@ function App() { setRunId(currentRunId) // Update active chat tab's runId to the new run setChatTabs((prev) => prev.map((tab) => ( - tab.id === activeChatTabId + tab.id === submitTabId ? { ...tab, runId: currentRunId } : tab ))) @@ -2329,11 +2361,12 @@ function App() { setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) setSelectedBackgroundTask(null) + setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ ...prev, [activeChatTabIdRef.current]: createEmptyChatTabViewState(), })) - }, []) + }, [setChatViewportAnchor]) // Chat tab operations const applyChatTab = useCallback((tab: ChatTab) => { @@ -2351,8 +2384,9 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setChatViewportAnchor(tab.id, null) } - }, [loadRun]) + }, [loadRun, setChatViewportAnchor]) const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => { const cached = chatViewStateByTabRef.current[tabId] @@ -3779,7 +3813,7 @@ function App() { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -3791,7 +3825,7 @@ function App() { } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
@@ -3811,7 +3845,7 @@ function App() { ) } return ( - + {item.content} @@ -3875,7 +3909,7 @@ function App() { if (isErrorMessage(item)) { return ( - +
{item.message}
@@ -4321,8 +4355,11 @@ function App() { data-chat-tab-panel={tab.id} aria-hidden={!isActive} > - - + {!tabHasConversation ? ( @@ -4384,6 +4421,7 @@ function App() { )} +
) @@ -4460,6 +4498,7 @@ function App() { conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} + viewportAnchors={chatViewportAnchorByTab} isProcessing={isProcessing} isStopping={isStopping} onStop={handleStop} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index f1d514da..7a3f8836 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,163 +3,254 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; +import type { ComponentProps, ReactNode, RefObject } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; -// Context to share scroll preservation state -interface ScrollPreservationContextValue { - registerScrollContainer: (container: HTMLElement | null) => void; - markUserEngaged: () => void; - resetEngagement: () => void; +const BOTTOM_THRESHOLD_PX = 8; +const MAX_ANCHOR_RETRIES = 6; + +interface ConversationContextValue { + contentRef: RefObject; + isAtBottom: boolean; + scrollRef: RefObject; + scrollToBottom: () => void; } -const ScrollPreservationContext = createContext(null); +const ConversationContext = createContext(null); -export type ConversationProps = ComponentProps & { +export type ConversationProps = ComponentProps<"div"> & { + anchorMessageId?: string | null; + anchorRequestKey?: number; children?: ReactNode; }; -export const Conversation = ({ className, children, ...props }: ConversationProps) => { - const [scrollContainer, setScrollContainer] = useState(null); - const isUserEngagedRef = useRef(false); - const savedScrollTopRef = useRef(0); - const lastScrollHeightRef = useRef(0); +export const Conversation = ({ + anchorMessageId = null, + anchorRequestKey, + children, + className, + ...props +}: ConversationProps) => { + const contentRef = useRef(null); + const scrollRef = useRef(null); + const spacerRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); - const contextValue: ScrollPreservationContextValue = { - registerScrollContainer: (container) => { - setScrollContainer(container); - }, - markUserEngaged: () => { - // Only save position on first engagement, not on repeated calls - if (!isUserEngagedRef.current && scrollContainer) { - savedScrollTopRef.current = scrollContainer.scrollTop; - lastScrollHeightRef.current = scrollContainer.scrollHeight; + const updateBottomState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX); + }, []); + + const applyAnchorLayout = useCallback( + (scrollToAnchor: boolean): boolean => { + const container = scrollRef.current; + const content = contentRef.current; + const spacer = spacerRef.current; + + if (!container || !content || !spacer) { + return false; } - isUserEngagedRef.current = true; - }, - resetEngagement: () => { - isUserEngagedRef.current = false; - }, - }; - // Watch for content changes and restore scroll position if user was engaged + if (!anchorMessageId) { + spacer.style.height = "0px"; + updateBottomState(); + return true; + } + + const anchor = content.querySelector( + `[data-message-id="${anchorMessageId}"]` + ); + + if (!anchor) { + spacer.style.height = "0px"; + updateBottomState(); + return false; + } + + spacer.style.height = "0px"; + + const contentPaddingTop = Number.parseFloat( + window.getComputedStyle(content).paddingTop || "0" + ); + const anchorTop = anchor.offsetTop; + const targetScrollTop = Math.max(0, anchorTop - contentPaddingTop); + const requiredSlack = Math.max( + 0, + targetScrollTop - (content.scrollHeight - container.clientHeight) + ); + + spacer.style.height = `${Math.ceil(requiredSlack)}px`; + + if (scrollToAnchor) { + container.scrollTop = targetScrollTop; + } + + updateBottomState(); + return true; + }, + [anchorMessageId, updateBottomState] + ); + useEffect(() => { - if (!scrollContainer) return; + const container = scrollRef.current; + if (!container) return; + + const handleScroll = () => { + updateBottomState(); + }; + + handleScroll(); + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, [updateBottomState]); + + useLayoutEffect(() => { + const container = scrollRef.current; + const content = contentRef.current; + if (!container || !content) return; let rafId: number | null = null; - const checkAndRestoreScroll = () => { - if (!isUserEngagedRef.current) return; - - const currentScrollTop = scrollContainer.scrollTop; - const currentScrollHeight = scrollContainer.scrollHeight; - const savedScrollTop = savedScrollTopRef.current; - - // If scroll position jumped significantly (auto-scroll happened) - // and scroll height also changed (content changed), restore position - if ( - Math.abs(currentScrollTop - savedScrollTop) > 50 && - currentScrollHeight !== lastScrollHeightRef.current - ) { - scrollContainer.scrollTop = savedScrollTop; + const schedule = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); } - - lastScrollHeightRef.current = currentScrollHeight; + rafId = requestAnimationFrame(() => { + applyAnchorLayout(false); + }); }; - // Use ResizeObserver to detect content changes - const resizeObserver = new ResizeObserver(() => { - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(checkAndRestoreScroll); - }); - - resizeObserver.observe(scrollContainer); + const observer = new ResizeObserver(schedule); + observer.observe(container); + observer.observe(content); + schedule(); return () => { - resizeObserver.disconnect(); - if (rafId) cancelAnimationFrame(rafId); + observer.disconnect(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } }; - }, [scrollContainer]); + }, [applyAnchorLayout]); + + useLayoutEffect(() => { + if (anchorRequestKey === undefined) return; + + let attempts = 0; + let rafId: number | null = null; + + const tryAnchor = () => { + if (applyAnchorLayout(true)) { + return; + } + if (attempts >= MAX_ANCHOR_RETRIES) { + return; + } + attempts += 1; + rafId = requestAnimationFrame(tryAnchor); + }; + + tryAnchor(); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [anchorRequestKey, applyAnchorLayout]); + + const scrollToBottom = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + updateBottomState(); + }, [updateBottomState]); + + const contextValue = useMemo( + () => ({ + contentRef, + isAtBottom, + scrollRef, + scrollToBottom, + }), + [isAtBottom, scrollToBottom] + ); return ( - - +
- {children} - - +
+ {children} + +
+ ); }; -/** - * Component that tracks scroll engagement and preserves position. - * Must be used inside Conversation component. - */ -export const ScrollPositionPreserver = () => { - const { isAtBottom, scrollRef } = useStickToBottomContext(); - const preservationContext = useContext(ScrollPreservationContext); - const containerFoundRef = useRef(false); +const useConversationContext = () => { + const context = useContext(ConversationContext); - // Find and register scroll container on mount - useLayoutEffect(() => { - if (containerFoundRef.current || !preservationContext) return; + if (!context) { + throw new Error( + "Conversation components must be used within a Conversation component." + ); + } - // Use the local StickToBottom scroll container for this conversation instance. - const container = scrollRef.current; - if (container) { - preservationContext.registerScrollContainer(container); - containerFoundRef.current = true; - } - }, [preservationContext, scrollRef]); - - // Track engagement based on scroll position - useEffect(() => { - if (!preservationContext) return; - - if (!isAtBottom) { - // User is not at bottom - mark as engaged - preservationContext.markUserEngaged(); - } else { - // User is back at bottom - reset - preservationContext.resetEngagement(); - } - }, [isAtBottom, preservationContext]); - - return null; + return context; }; -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content ->; +export type ConversationContentProps = ComponentProps<"div">; export const ConversationContent = ({ className, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const { contentRef } = useConversationContext(); + + return ( +
+ ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { - title?: string; description?: string; - icon?: React.ReactNode; + icon?: ReactNode; + title?: string; }; export const ConversationEmptyState = ({ + children, className, - title = "No messages yet", description = "Start a conversation to see messages here", icon, - children, + title = "No messages yet", ...props }: ConversationEmptyStateProps) => (
); +export const ScrollPositionPreserver = () => null; + export type ConversationScrollButtonProps = ComponentProps; export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + const { isAtBottom, scrollToBottom } = useConversationContext(); const handleScrollToBottom = useCallback(() => { scrollToBottom(); @@ -199,16 +292,16 @@ export const ConversationScrollButton = ({ !isAtBottom && ( ) ); diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 160d8fb1..64b1e843 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -8,7 +8,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, - ScrollPositionPreserver, + ConversationScrollButton, } from '@/components/ai-elements/conversation' import { Message, @@ -30,6 +30,7 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat- import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { + type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, type PermissionResponse, @@ -90,6 +91,7 @@ interface ChatSidebarProps { conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record + viewportAnchors?: Record isProcessing: boolean isStopping?: boolean onStop?: () => void @@ -142,6 +144,7 @@ export function ChatSidebar({ conversation, currentAssistantMessage, chatTabStates = {}, + viewportAnchors = {}, isProcessing, isStopping, onStop, @@ -289,7 +292,7 @@ export function ChatSidebar({ if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { return ( - + @@ -301,7 +304,7 @@ export function ChatSidebar({ } const { message, files } = parseAttachedFiles(item.content) return ( - + {files.length > 0 && (
@@ -321,7 +324,7 @@ export function ChatSidebar({ ) } return ( - + {item.content} @@ -376,7 +379,7 @@ export function ChatSidebar({ if (isErrorMessage(item)) { return ( - +
{item.message}
@@ -485,9 +488,12 @@ export function ChatSidebar({ )} data-chat-tab-panel={tab.id} aria-hidden={!isActive} - > - - + > + {!tabHasConversation ? ( @@ -545,10 +551,11 @@ export function ChatSidebar({
)} - )} - - -
+ )} + + + +
) })}
diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index af01cfdd..68c8366d 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -47,6 +47,11 @@ export type ChatTabViewState = { permissionResponses: Map } +export type ChatViewportAnchorState = { + messageId: string | null + requestKey: number +} + export const createEmptyChatTabViewState = (): ChatTabViewState => ({ runId: null, conversation: [],