Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/browser/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ export function useAutoScroll() {
const autoScrollRef = useRef<boolean>(true);
// Track the ResizeObserver so we can disconnect it when the element unmounts
const observerRef = useRef<ResizeObserver | null>(null);
// Track pending RAF to coalesce rapid resize events
const rafIdRef = useRef<number | null>(null);

// Sync ref with state to ensure callbacks always have latest value
autoScrollRef.current = autoScroll;
// Track pending RAF to coalesce rapid resize events
const rafIdRef = useRef<number | null>(null);

// Callback ref for the inner content wrapper - sets up ResizeObserver when element mounts.
// ResizeObserver fires when the content size changes (Shiki highlighting, Mermaid, images, etc.),
Expand All @@ -52,10 +52,10 @@ export function useAutoScroll() {
// Skip if auto-scroll is disabled (user scrolled up)
if (!autoScrollRef.current || !contentRef.current) return;

// Defer layout read to next frame to avoid forcing synchronous layout
// during React's commit phase (which can cause 50-85ms layout thrashing)
if (rafIdRef.current !== null) return; // Coalesce rapid calls
rafIdRef.current = requestAnimationFrame(() => {
// Coalesce all resize events in a frame into one scroll operation.
// Without this, rapid resize events (Shiki highlighting, etc.) cause
// multiple scrolls per frame with slightly different scrollHeight values.
rafIdRef.current ??= requestAnimationFrame(() => {
rafIdRef.current = null;
if (autoScrollRef.current && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
Expand Down
21 changes: 10 additions & 11 deletions src/browser/stories/App.bash.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,20 @@ import {
createBashBackgroundTerminateTool,
} from "./mockFactory";
import { setupSimpleChatStory } from "./storyHelpers";
import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js";
import {
blurActiveElement,
waitForChatInputAutofocusDone,
waitForChatMessagesLoaded,
} from "./storyPlayHelpers.js";
import { userEvent, waitFor } from "@storybook/test";

/**
* Helper to expand all bash tool calls in a story.
* Waits for messages to load, then clicks on the â–¶ expand icons to expand tool details.
*/
async function expandAllBashTools(canvasElement: HTMLElement) {
// Wait for messages to finish loading (non-racy: uses actual loading state)
await waitFor(
() => {
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
throw new Error("Messages not loaded yet");
}
},
{ timeout: 5000 }
);
// Wait for messages to finish loading
await waitForChatMessagesLoaded(canvasElement);

// Now find and expand all tool icons (scoped to the message window so we don't click unrelated â–¶)
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
Expand Down Expand Up @@ -64,6 +60,9 @@ async function expandAllBashTools(canvasElement: HTMLElement) {
}
}

// One RAF to let any pending coalesced scroll complete after tool expansion
await new Promise((r) => requestAnimationFrame(r));

// Avoid leaving focus on a tool header.
await waitForChatInputAutofocusDone(canvasElement);
blurActiveElement();
Expand Down
21 changes: 1 addition & 20 deletions src/browser/stories/App.markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,7 @@
import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory";
import { expect, waitFor } from "@storybook/test";

async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise<void> {
await waitFor(
() => {
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
throw new Error("Messages not loaded yet");
}
},
{ timeout: 5000 }
);
}
import { waitForChatMessagesLoaded } from "./storyPlayHelpers";

import { setupSimpleChatStory } from "./storyHelpers";

Expand Down Expand Up @@ -273,14 +262,6 @@ export const CodeBlocks: AppStory = {
{ timeout: 5000 }
);

// Scroll to bottom and wait a frame for ResizeObserver to settle.
// Shiki highlighting can trigger useAutoScroll's ResizeObserver, causing scroll jitter.
const scrollContainer = canvasElement.querySelector('[data-testid="message-window"]');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
await new Promise((r) => requestAnimationFrame(r));
}

const url = "https://github.com/coder/mux/pull/new/chat-autocomplete-b24r";
const container = await waitFor(
() => {
Expand Down
21 changes: 21 additions & 0 deletions src/browser/stories/storyPlayHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { waitFor } from "@storybook/test";

/**
* Wait for chat messages to finish loading.
*
* Waits for data-loaded="true" on the message window, then one RAF
* to let any pending coalesced scroll from useAutoScroll complete.
*/
export async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise<void> {
await waitFor(
() => {
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
throw new Error("Messages not loaded yet");
}
},
{ timeout: 5000 }
);

// One RAF to let any pending coalesced scroll complete
await new Promise((r) => requestAnimationFrame(r));
}

export async function waitForChatInputAutofocusDone(canvasElement: HTMLElement): Promise<void> {
await waitFor(
() => {
Expand Down