fix assistant scroll

This commit is contained in:
Arjun 2026-04-07 07:33:40 +05:30
parent e0aaa9a27e
commit 5965e96f32
4 changed files with 266 additions and 130 deletions

View file

@ -20,7 +20,6 @@ import {
Conversation,
ConversationContent,
ConversationEmptyState,
ScrollPositionPreserver,
} from '@/components/ai-elements/conversation';
import {
Message,
@ -62,6 +61,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 +841,7 @@ function App() {
const chatDraftsRef = useRef(new Map<string, string>())
const chatScrollTopByTabRef = useRef(new Map<string, number>())
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
const activeChatTabIdRef = useRef(activeChatTabId)
activeChatTabIdRef.current = activeChatTabId
const setChatDraftForTab = useCallback((tabId: string, text: string) => {
@ -866,6 +867,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<HTMLElement>(
@ -967,6 +980,22 @@ function App() {
})
}, [chatTabs])
useEffect(() => {
const tabIds = new Set(chatTabs.map((tab) => tab.id))
setChatViewportAnchorByTab((prev) => {
let changed = false
const next: Record<string, ChatViewportAnchorState> = {}
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<string>('')
@ -2095,6 +2124,7 @@ function App() {
) => {
if (isProcessing) return
const submitTabId = activeChatTabIdRef.current
const { text } = message
const userMessage = text.trim()
const hasAttachments = stagedAttachments.length > 0
@ -2119,6 +2149,7 @@ function App() {
attachments: displayAttachments,
timestamp: Date.now(),
}])
setChatViewportAnchor(submitTabId, userMessageId)
try {
let currentRunId = runId
@ -2133,7 +2164,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 +2360,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 +2383,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 +3812,7 @@ function App() {
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
@ -3791,7 +3824,7 @@ function App() {
}
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@ -3811,7 +3844,7 @@ function App() {
)
}
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
</MessageContent>
@ -3875,7 +3908,7 @@ function App() {
if (isErrorMessage(item)) {
return (
<Message key={item.id} from="assistant">
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
</MessageContent>
@ -4321,8 +4354,11 @@ function App() {
data-chat-tab-panel={tab.id}
aria-hidden={!isActive}
>
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver />
<Conversation
anchorMessageId={chatViewportAnchorByTab[tab.id]?.messageId}
anchorRequestKey={chatViewportAnchorByTab[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabConversationContentClassName}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
@ -4460,6 +4496,7 @@ function App() {
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}
chatTabStates={chatViewStateByTab}
viewportAnchors={chatViewportAnchorByTab}
isProcessing={isProcessing}
isStopping={isStopping}
onStop={handleStop}

View file

@ -3,163 +3,250 @@
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<HTMLDivElement | null>;
isAtBottom: boolean;
scrollRef: RefObject<HTMLDivElement | null>;
scrollToBottom: () => void;
}
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
const ConversationContext = createContext<ConversationContextValue | null>(null);
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
export type ConversationProps = ComponentProps<"div"> & {
anchorMessageId?: string | null;
anchorRequestKey?: number;
children?: ReactNode;
};
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
const isUserEngagedRef = useRef(false);
const savedScrollTopRef = useRef<number>(0);
const lastScrollHeightRef = useRef<number>(0);
export const Conversation = ({
anchorMessageId = null,
anchorRequestKey,
children,
className,
...props
}: ConversationProps) => {
const contentRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const spacerRef = useRef<HTMLDivElement | null>(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<HTMLElement>(
`[data-message-id="${anchorMessageId}"]`
);
if (!anchor) {
spacer.style.height = "0px";
updateBottomState();
return false;
}
spacer.style.height = "0px";
const anchorTop = anchor.offsetTop;
const requiredSlack = Math.max(
0,
anchorTop - (content.scrollHeight - container.clientHeight)
);
spacer.style.height = `${Math.ceil(requiredSlack)}px`;
if (scrollToAnchor) {
container.scrollTop = anchorTop;
}
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<ConversationContextValue>(
() => ({
contentRef,
isAtBottom,
scrollRef,
scrollToBottom,
}),
[isAtBottom, scrollToBottom]
);
return (
<ScrollPreservationContext.Provider value={contextValue}>
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
<ConversationContext.Provider value={contextValue}>
<div
className={cn("relative flex-1 overflow-hidden", className)}
role="log"
{...props}
>
{children}
</StickToBottom>
</ScrollPreservationContext.Provider>
<div
className="h-full w-full overflow-y-auto [scrollbar-gutter:stable]"
ref={scrollRef}
>
{children}
<div ref={spacerRef} aria-hidden="true" />
</div>
</div>
</ConversationContext.Provider>
);
};
/**
* 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) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
}: ConversationContentProps) => {
const { contentRef } = useConversationContext();
return (
<div
className={cn("flex flex-col gap-8 p-4", className)}
ref={contentRef}
{...props}
/>
);
};
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) => (
<div
@ -183,13 +270,15 @@ export const ConversationEmptyState = ({
</div>
);
export const ScrollPositionPreserver = () => null;
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const { isAtBottom, scrollToBottom } = useConversationContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();

View file

@ -8,7 +8,6 @@ import {
Conversation,
ConversationContent,
ConversationEmptyState,
ScrollPositionPreserver,
} from '@/components/ai-elements/conversation'
import {
Message,
@ -30,6 +29,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 +90,7 @@ interface ChatSidebarProps {
conversation: ConversationItem[]
currentAssistantMessage: string
chatTabStates?: Record<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
@ -142,6 +143,7 @@ export function ChatSidebar({
conversation,
currentAssistantMessage,
chatTabStates = {},
viewportAnchors = {},
isProcessing,
isStopping,
onStop,
@ -289,7 +291,7 @@ export function ChatSidebar({
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
@ -301,7 +303,7 @@ export function ChatSidebar({
}
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
@ -321,7 +323,7 @@ export function ChatSidebar({
)
}
return (
<Message key={item.id} from={item.role}>
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
</MessageContent>
@ -376,7 +378,7 @@ export function ChatSidebar({
if (isErrorMessage(item)) {
return (
<Message key={item.id} from="assistant">
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
</MessageContent>
@ -486,8 +488,11 @@ export function ChatSidebar({
data-chat-tab-panel={tab.id}
aria-hidden={!isActive}
>
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver />
<Conversation
anchorMessageId={viewportAnchors[tab.id]?.messageId}
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">

View file

@ -47,6 +47,11 @@ export type ChatTabViewState = {
permissionResponses: Map<string, PermissionResponse>
}
export type ChatViewportAnchorState = {
messageId: string | null
requestKey: number
}
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],