diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
index 640807acce..62381b67e8 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
@@ -7,7 +7,8 @@ import {
render,
screen,
} from "@testing-library/react";
-import type { ReactNode } from "react";
+import { Profiler, startTransition, type ReactNode } from "react";
+import { flushSync } from "react-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetPluginSlotStoreForTest,
@@ -258,6 +259,44 @@ beforeEach(() => {
});
describe("FollowUpPromptBox", () => {
+ it("does not commit an unchanged measurement while a height update is pending", () => {
+ const onRender = vi.fn();
+ render(
+
+ Stack}
+ />
+ ,
+ );
+ const stackElement = screen.getByTestId("measured-stack").parentElement;
+ if (!stackElement) throw new Error("Expected measured composer stack");
+ Object.defineProperty(stackElement, "offsetHeight", {
+ configurable: true,
+ value: 24,
+ });
+ let commitsAfterSynchronousSignal = -1;
+ const resizeEntries = [
+ { contentRect: { height: 24 } } as ResizeObserverEntry,
+ ];
+
+ act(() => {
+ startTransition(() => {
+ resizeObserverCallback?.(resizeEntries, {} as ResizeObserver);
+ });
+ flushSync(() => {
+ resizeObserverCallback?.(resizeEntries, {} as ResizeObserver);
+ });
+ commitsAfterSynchronousSignal = onRender.mock.calls.length;
+ });
+
+ expect(commitsAfterSynchronousSignal).toBe(1);
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender.mock.calls[0]?.[1]).toBe("mount");
+ expect(onRender.mock.calls[1]?.[1]).toBe("update");
+ expect(screen.getByTestId("prompt-box").dataset.minHeight).toBe("76");
+ });
+
it("includes expanding plugin banners in measured stack compensation", () => {
setPluginSlotRegistrations("measured-banner", {
homepageSections: [],
@@ -300,17 +339,25 @@ describe("FollowUpPromptBox", () => {
expect(screen.getByText("Expandable plugin banner")).toBeTruthy();
const promptBox = screen.getByTestId("prompt-box");
const initialMinHeight = Number(promptBox.getAttribute("data-min-height"));
+ const stackElement = screen
+ .getByText("Expandable plugin banner")
+ .closest("[data-bb-plugin-root]")?.parentElement;
+ if (!stackElement) throw new Error("Expected measured composer stack");
+ Object.defineProperty(stackElement, "offsetHeight", {
+ configurable: true,
+ value: 24,
+ });
act(() => {
resizeObserverCallback?.(
- [{ contentRect: { height: 24 } } as ResizeObserverEntry],
+ [{ contentRect: { height: 999 } } as ResizeObserverEntry],
{} as ResizeObserver,
);
+ resizeObserverCallback?.([], {} as ResizeObserver);
});
- expect(Number(promptBox.getAttribute("data-min-height"))).toBeLessThan(
- initialMinHeight,
- );
+ expect(initialMinHeight).toBe(100);
+ expect(promptBox.getAttribute("data-min-height")).toBe("76");
});
it("renders plugin banners above native stack content", () => {
diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
index b081072c9e..97cad681b0 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
@@ -538,7 +538,16 @@ function FollowUpPromptBoxWithComposer({
],
);
const stackRef = useRef(null);
+ const lastStackHeightRef = useRef(0);
const [stackHeight, setStackHeight] = useState(0);
+ const measureStackHeight = useCallback(() => {
+ const element = stackRef.current;
+ if (!element) return;
+ const measured = element.offsetHeight;
+ if (lastStackHeightRef.current === measured) return;
+ lastStackHeightRef.current = measured;
+ setStackHeight(measured);
+ }, []);
// Measure the stack synchronously after every render. useLayoutEffect runs
// post-DOM-commit and pre-paint, so when a React commit adds the banner
// (e.g. workspace status arrives and the git section becomes non-empty),
@@ -547,26 +556,17 @@ function FollowUpPromptBoxWithComposer({
// browser paints. Without this, the banner appears at 32px while the
// textarea is still 100px for one frame — the timeline visibly shifts up
// then back down as the elastic compensation catches up.
- useLayoutEffect(() => {
- const element = stackRef.current;
- if (!element) return;
- const measured = element.offsetHeight;
- setStackHeight((prev) => (prev === measured ? prev : measured));
- }, [stack]);
+ useLayoutEffect(measureStackHeight, [measureStackHeight, stack]);
// ResizeObserver catches changes that happen outside a React render —
// banner sections expanding via CSS animation, window resize affecting
// markdown line-wrapping inside the stack, etc.
useEffect(() => {
const element = stackRef.current;
if (!element || typeof ResizeObserver === "undefined") return;
- const observer = new ResizeObserver((entries) => {
- const entry = entries[0];
- if (!entry) return;
- setStackHeight(entry.contentRect.height);
- });
+ const observer = new ResizeObserver(measureStackHeight);
observer.observe(element);
return () => observer.disconnect();
- }, []);
+ }, [measureStackHeight]);
// The elastic pre-size keeps the prompt area's total height constant as the
// stack (context banner + queued messages) mounts/unmounts so the timeline
// doesn't shift. Callers that need the main-thread prompt height should pass