fix(chat): scroll-to-latest no longer cancels smooth scroll to bottom (#1112)

This commit is contained in:
paisley
2026-06-11 13:20:47 +08:00
committed by GitHub
parent bf038f5ca4
commit 1fd3c50c3e
10 changed files with 333 additions and 65 deletions
+11 -2
View File
@@ -81,11 +81,20 @@ export function useStickToBottomInstant(resetKey?: string, active = false) {
if (!element) return;
let lastScrollTop = element.scrollTop;
const handleScroll = () => {
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
if (distanceFromBottom > ESCAPE_FROM_LOCK_OFFSET_PX) {
// Only relevant while a run is actively pinning to the bottom.
if (!activeRef.current) return;
const scrollTop = element.scrollTop;
const distanceFromBottom = element.scrollHeight - element.clientHeight - scrollTop;
// Match the library's escape semantics: upward manual scroll only.
// Calling stopScroll while scrolling *down* (e.g. scroll-to-latest smooth
// animation) would cancel the animation before it reaches the bottom.
if (distanceFromBottom > ESCAPE_FROM_LOCK_OFFSET_PX && scrollTop < lastScrollTop) {
stopScroll();
}
lastScrollTop = scrollTop;
};
element.addEventListener("scroll", handleScroll, { passive: true });
scrollEscapeCleanupRef.current = () => element.removeEventListener("scroll", handleScroll);
+37 -10
View File
@@ -18,7 +18,7 @@ import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ExecutionGraphCard } from './ExecutionGraphCard';
import { ChatToolbar } from './ChatToolbar';
import { extractImages, extractText, extractThinking, extractToolUse, isInternalAssistantReplyText, isInternalProcessNarration, normalizeMessageRole, stripProcessMessagePrefix } from './message-utils';
import { extractImages, extractText, extractThinking, extractToolUse, isInternalAssistantReplyText, isInternalProcessNarration, normalizeMessageRole, sanitizeAssistantReplyText, stripProcessMessagePrefix } from './message-utils';
import {
buildRunSegmentMessageIndices,
deriveRuntimeTaskSteps,
@@ -576,7 +576,9 @@ export function Chat() {
: sanitizeGraphSteps(buildSteps(rawStreamingReplyCandidate));
let streamingReplyText: string | null = null;
if (rawStreamingReplyCandidate) {
const trimmedReplyText = stripProcessMessagePrefix(streamText, getPrimaryMessageStepTexts(steps));
const trimmedReplyText = sanitizeAssistantReplyText(
stripProcessMessagePrefix(streamText, getPrimaryMessageStepTexts(steps)),
);
const hasReplyText = trimmedReplyText.trim().length > 0
&& !isInternalAssistantReplyText(trimmedReplyText);
if (hasReplyText || hasStreamImages) {
@@ -775,7 +777,9 @@ export function Chat() {
const replyMessage = messages[card.replyIndex];
if (!replyMessage || replyMessage.role !== 'assistant') continue;
const fullReplyText = extractText(replyMessage);
const trimmedReplyText = stripProcessMessagePrefix(fullReplyText, card.messageStepTexts);
const trimmedReplyText = sanitizeAssistantReplyText(
stripProcessMessagePrefix(fullReplyText, card.messageStepTexts),
);
if (trimmedReplyText !== fullReplyText) {
map.set(card.replyIndex, trimmedReplyText);
}
@@ -1203,14 +1207,37 @@ export function Chat() {
function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
const { t } = useTranslation('chat');
const scrollRef = useRef<HTMLElement | null>(null);
const visibleItems = items.slice(0, QUESTION_DIRECTORY_RENDER_LIMIT);
const visibleItems =
items.length > QUESTION_DIRECTORY_RENDER_LIMIT
? items.slice(-QUESTION_DIRECTORY_RENDER_LIMIT)
: items;
const hiddenCount = Math.max(0, items.length - visibleItems.length);
const lastItemKey = visibleItems.at(-1)?.index ?? -1;
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;
scrollEl.scrollTop = scrollEl.scrollHeight;
}, [visibleItems.length]);
const scrollToEnd = () => {
scrollEl.scrollTop = scrollEl.scrollHeight;
};
scrollToEnd();
const frame = requestAnimationFrame(() => {
requestAnimationFrame(scrollToEnd);
});
if (typeof ResizeObserver === 'undefined') {
return () => cancelAnimationFrame(frame);
}
const observer = new ResizeObserver(scrollToEnd);
observer.observe(scrollEl);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [lastItemKey, visibleItems.length]);
const handleJumpToMessage = (index: number) => {
document.getElementById(`chat-message-${index}`)?.scrollIntoView({
@@ -1222,11 +1249,11 @@ function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
return (
<aside
data-testid="chat-question-directory"
className="w-full shrink-0 lg:w-64 xl:w-72"
className="flex min-h-0 w-full shrink-0 self-stretch lg:w-64 xl:w-72"
aria-label={t('questionDirectory.title')}
>
<div className="sticky top-2 max-h-full overflow-hidden rounded-2xl border border-black/5 bg-black/[0.02] p-3 shadow-sm dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<div className="sticky top-2 flex min-h-0 w-full flex-1 flex-col rounded-2xl border border-black/5 bg-black/[0.02] p-3 shadow-sm dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 px-1">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('questionDirectory.title')}
</h2>
@@ -1234,7 +1261,7 @@ function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
{items.length}
</span>
</div>
<nav ref={scrollRef} className="max-h-[calc(100vh-13rem)] space-y-1 overflow-y-auto pr-1">
<nav ref={scrollRef} className="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain pr-1">
{visibleItems.map((item) => (
<button
key={item.index}
+42 -10
View File
@@ -77,12 +77,14 @@ function stripAssistantMediaTags(text: string): string {
// are also stripped from the visible bubble. Without this, the bubble
// would still leak the literal `MEDIA:/.../截屏 2026-05-06 17.46.51.png`
// to the user when the underlying path detection succeeds.
const tagged = new RegExp(`(^|[\\s(\\[{>])(?:MEDIA|media):(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
// Allow MEDIA: after punctuation/emojis (e.g. `必备~MEDIA:/path`) — only reject
// when immediately preceded by word/path characters so `someMEDIA:` stays literal.
const tagged = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
// Bare OpenClaw artifact paths emitted alongside `_attachedFiles` cards.
// Scope to `.openclaw/media/` so normal absolute paths in prose stay visible.
const bareOpenClawMedia = new RegExp(`(^|[\\s(\\[{>])(?:(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.openclaw[\\\\/]media[\\\\/][^\\n"'()\\[\\],<>]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
return text
.replace(tagged, (_, lead: string) => lead)
.replace(tagged, '')
.replace(bareOpenClawMedia, (_, lead: string) => lead)
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Collapse the empty lines / orphan whitespace the strip leaves behind.
@@ -213,6 +215,40 @@ export function isOpenClawRuntimeEventPrompt(text: string): boolean {
return trimmed.split(/\n+/).some((line) => /^Continue the OpenClaw runtime event\.?\s*$/i.test(line.trim()));
}
/** Model-side planning about message-tool vs MEDIA delivery — never user-facing. */
function isInternalDeliveryPlanningParagraph(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (/message tool isn't suitable/i.test(trimmed)) return true;
if (/visible-reply contract/i.test(trimmed)) return true;
if (/final-reply MEDIA lines/i.test(trimmed)) return true;
if (/writing the normal final reply with MEDIA directives/i.test(trimmed)) return true;
if (/webchat isn't a valid channel for the message tool/i.test(trimmed)) return true;
if (/fall back to writing the normal final reply/i.test(trimmed)) return true;
return false;
}
/** Remove internal delivery-planning paragraphs the model sometimes prepends to replies. */
export function stripInternalDeliveryPlanning(text: string): string {
if (!text) return text;
const paragraphs = text.split(/\n{2,}/);
const kept = paragraphs.filter((paragraph) => !isInternalDeliveryPlanningParagraph(paragraph));
if (kept.length === paragraphs.length) return text;
return kept
.join('\n\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Normalize assistant reply text for chat bubbles and stream overrides. */
export function sanitizeAssistantReplyText(text: string): string {
if (!text) return text;
return stripInternalSentinelLines(
stripAssistantMediaTags(stripInternalDeliveryPlanning(text)),
);
}
/** Process narration that should never appear in the execution graph or chat stream. */
export function isInternalProcessNarration(text: string): boolean {
const trimmed = text.trim();
@@ -220,6 +256,7 @@ export function isInternalProcessNarration(text: string): boolean {
if (isInternalAssistantReplyText(trimmed)) return true;
if (isGeneratingStatusNarration(trimmed)) return true;
if (isOpenClawRuntimeEventPrompt(trimmed)) return true;
if (isInternalDeliveryPlanningParagraph(trimmed)) return true;
if (/^\[Inter-session message\]/i.test(trimmed)) return true;
if (/OpenClaw runtime event/i.test(trimmed)) return true;
if (/Handle the result internally/i.test(trimmed) && /Do not relay it to the user/i.test(trimmed)) {
@@ -278,14 +315,9 @@ export function extractText(message: RawMessage | unknown): string {
} else if (!isUser && result) {
if (isInternalAssistantReplyText(result)) return '';
if (isGeneratingStatusNarration(result)) return '';
// Assistant-side cleanup: keep the bubble free of `MEDIA:/path` tags
// that the runtime emits to point at produced artifacts. The same
// path is surfaced as a clickable file card via `_attachedFiles`,
// so leaving it inline would duplicate the artifact.
result = stripAssistantMediaTags(result);
// Drop a trailing `NO_REPLY` / `HEARTBEAT_OK` the model may append after
// an otherwise-real answer.
result = stripInternalSentinelLines(result);
// Assistant-side cleanup: drop internal delivery planning, `MEDIA:/path`
// tags, and trailing sentinels. Artifact paths surface via `_attachedFiles`.
result = sanitizeAssistantReplyText(result);
}
return result;
+1 -1
View File
@@ -1089,7 +1089,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType:
// and other space-containing paths the agent emits with the explicit
// `MEDIA:` marker still resolve. Newline and quote characters remain
// path terminators so we don't accidentally swallow trailing prose.
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
const taggedRegex = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
let workingText = text;
let taggedMatch: RegExpExecArray | null;
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
+1 -1
View File
@@ -789,7 +789,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType:
// path terminators so we don't accidentally swallow trailing prose.
// The non-greedy `*?` anchored to `\.<ext>` keeps the match minimal so
// multiple `MEDIA:` markers in one paragraph still match independently.
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
const taggedRegex = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
let workingText = text;
let taggedMatch: RegExpExecArray | null;
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
+90 -40
View File
@@ -28,51 +28,69 @@ const seededHistory = [
{ role: 'assistant', content: 'Here is the final action plan.', timestamp: 1007 },
];
const latestQuestion = '给我生成一只哈密瓜';
const longQuestionDirectoryHistory = [
...Array.from({ length: 14 }, (_, idx) => ([
{ role: 'user', content: `Question ${idx + 1}: generate an image.`, timestamp: 2000 + idx * 2 },
{ role: 'assistant', content: `Answer ${idx + 1}.`, timestamp: 2001 + idx * 2 },
])).flat(),
{ role: 'user', content: latestQuestion, timestamp: 3000 },
{ role: 'assistant', content: 'Here is the cantaloupe image.', timestamp: 3001 },
];
async function installQuestionDirectoryMocks(
app: Awaited<ReturnType<typeof import('./fixtures/electron').launchElectronApp>>,
messages: Array<{ role: string; content: string; timestamp: number }>,
) {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{ id: 'main', name: 'main' }],
},
},
},
},
});
}
test.describe('ClawX chat question directory', () => {
test('shows a toolbar button that opens a clickable in-conversation question directory', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{ id: 'main', name: 'main' }],
},
},
},
},
});
await installQuestionDirectoryMocks(app, seededHistory);
const page = await getStableWindow(app);
await page.setViewportSize({ width: 1600, height: 900 });
@@ -103,4 +121,36 @@ test.describe('ClawX chat question directory', () => {
await closeElectronApp(app);
}
});
test('scrolls the question directory to show the latest question', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installQuestionDirectoryMocks(app, longQuestionDirectoryHistory);
const page = await getStableWindow(app);
await page.setViewportSize({ width: 1600, height: 900 });
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
throw error;
}
}
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.getByTestId('chat-question-directory-toggle').click();
const directory = page.getByTestId('chat-question-directory');
await expect(directory).toBeVisible({ timeout: 30_000 });
await expect(directory).toContainText('15');
const lastItem = page.getByTestId(`chat-question-directory-item-${longQuestionDirectoryHistory.length - 2}`);
await expect(lastItem).toBeVisible();
await expect(lastItem).toContainText(latestQuestion);
await expect(lastItem).toBeInViewport();
} finally {
await closeElectronApp(app);
}
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractText } from '@/pages/Chat/message-utils';
import { extractText, sanitizeAssistantReplyText } from '@/pages/Chat/message-utils';
describe('assistant media path display cleanup', () => {
it('strips bare OpenClaw media paths when the image is shown as an attachment card', () => {
@@ -37,4 +37,22 @@ C:\Users\alice\.openclaw\media\outbound\cat---abc.png`;
expect(extractText({ role: 'assistant', content: text })).toBe('宇航员图片完成啦 🧑‍🚀✨');
});
it('strips internal delivery-planning narration before the user-facing caption', () => {
const text = [
"The message tool isn't suitable here since I'm in a webchat session with no proper routing target. The runtime context says 'Use the current visible-reply contract... Otherwise, write the normal final reply and attach every generated media path with final-reply MEDIA lines.' Since webchat isn't a valid channel for the message tool, I should fall back to writing the normal final reply with MEDIA directives.",
'西瓜切片来了 🍉 红透多汁,夏日续命必备~',
'MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---4d2c1ef7-0d16-451c-9c09-9b58c4c99846.png',
].join('\n\n');
expect(extractText({ role: 'assistant', content: text })).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~');
expect(sanitizeAssistantReplyText(text)).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~');
});
it('strips inline MEDIA: markers glued to caption punctuation', () => {
const text =
'橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png';
expect(extractText({ role: 'assistant', content: text })).toBe('橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~');
});
});
@@ -84,6 +84,18 @@ MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`);
]);
});
it('captures inline MEDIA: markers glued to caption punctuation', () => {
const sample =
'橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png';
const refs = extractRawFilePaths(sample);
expect(refs).toEqual([
{
filePath: '/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png',
mimeType: 'image/png',
},
]);
});
it('keeps non-MEDIA prose after a space-bearing path out of the captured filename', () => {
// The lookahead must terminate the match at the first non-path character
// (newline, quote, paren, comma, full-stop, ...). Otherwise a long line
@@ -119,4 +119,32 @@ describe('Chat question directory', () => {
expect(directory).toBeInTheDocument();
expect(directory.querySelectorAll('button')).toHaveLength(2);
});
it('includes the latest question in the directory list', async () => {
const latestQuestion = '给我生成一只哈密瓜';
const originalMessages = chatState.messages;
chatState.messages = [
...Array.from({ length: 13 }, (_, idx) => ([
{ role: 'user', content: `question ${idx + 1}` },
{ role: 'assistant', content: `reply ${idx + 1}` },
])).flat(),
{ role: 'user', content: latestQuestion },
{ role: 'assistant', content: 'generated image' },
];
try {
render(
<TooltipProvider>
<Chat />
</TooltipProvider>,
);
fireEvent.click(await screen.findByTestId('chat-question-directory-toggle'));
const lastUserIndex = chatState.messages.length - 2;
expect(screen.getByTestId(`chat-question-directory-item-${lastUserIndex}`)).toHaveTextContent(latestQuestion);
} finally {
chatState.messages = originalMessages;
}
});
});
@@ -0,0 +1,92 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockStopScroll = vi.fn();
const mockScrollToBottom = vi.fn();
let mockEscapedFromLock = false;
vi.mock('use-stick-to-bottom', () => ({
useStickToBottom: () => ({
contentRef: vi.fn(),
scrollRef: vi.fn(),
scrollToBottom: mockScrollToBottom,
stopScroll: mockStopScroll,
isAtBottom: false,
isNearBottom: false,
escapedFromLock: mockEscapedFromLock,
state: {},
}),
}));
describe('useStickToBottomInstant', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEscapedFromLock = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('does not call stopScroll while scrolling down toward the bottom during an active run', async () => {
const { useStickToBottomInstant } = await import('@/hooks/use-stick-to-bottom-instant');
let scrollElement: HTMLDivElement | null = null;
const { result, rerender } = renderHook(
({ active }) => useStickToBottomInstant('session-1', active),
{ initialProps: { active: true } },
);
act(() => {
scrollElement = document.createElement('div');
Object.defineProperty(scrollElement, 'scrollHeight', { value: 2000, configurable: true });
Object.defineProperty(scrollElement, 'clientHeight', { value: 400, configurable: true });
scrollElement.scrollTop = 0;
result.current.scrollRef(scrollElement);
});
act(() => {
scrollElement!.scrollTop = 500;
scrollElement!.dispatchEvent(new Event('scroll'));
});
expect(mockStopScroll).not.toHaveBeenCalled();
rerender({ active: false });
act(() => {
scrollElement!.scrollTop = 0;
scrollElement!.dispatchEvent(new Event('scroll'));
});
expect(mockStopScroll).not.toHaveBeenCalled();
});
it('calls stopScroll when the user scrolls up away from the bottom during an active run', async () => {
const { useStickToBottomInstant } = await import('@/hooks/use-stick-to-bottom-instant');
let scrollElement: HTMLDivElement | null = null;
const { result } = renderHook(() => useStickToBottomInstant('session-1', true));
act(() => {
scrollElement = document.createElement('div');
Object.defineProperty(scrollElement, 'scrollHeight', { value: 2000, configurable: true });
Object.defineProperty(scrollElement, 'clientHeight', { value: 400, configurable: true });
scrollElement.scrollTop = 1500;
result.current.scrollRef(scrollElement);
});
act(() => {
scrollElement!.scrollTop = 1500;
scrollElement!.dispatchEvent(new Event('scroll'));
});
expect(mockStopScroll).not.toHaveBeenCalled();
act(() => {
scrollElement!.scrollTop = 1000;
scrollElement!.dispatchEvent(new Event('scroll'));
});
expect(mockStopScroll).toHaveBeenCalledTimes(1);
});
});