Skip to content
Closed
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
57 changes: 52 additions & 5 deletions apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
<Profiler id="follow-up-prompt-box" onRender={onRender}>
<FollowUpPromptBox
{...createFollowUpPromptBoxProps({ kind: "ready" })}
stack={<div data-testid="measured-stack">Stack</div>}
/>
</Profiler>,
);
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: [],
Expand Down Expand Up @@ -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", () => {
Expand Down
24 changes: 12 additions & 12 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,16 @@ function FollowUpPromptBoxWithComposer({
],
);
const stackRef = useRef<HTMLDivElement>(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),
Expand All @@ -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
Expand Down