Recompute collapsible content overflow after resize

This commit is contained in:
murashit 2026-07-10 13:14:00 +09:00
parent 0cb39fb6dc
commit d9024ba983
4 changed files with 71 additions and 4 deletions

View file

@ -1,4 +1,5 @@
import { disposeDomListeners, listenDomEscapeKey, listenOutsideDomEvent } from "../../../shared/dom/events.dom";
import { observeElementResize } from "../../../shared/dom/resize-observer.measure";
import { syncTextareaHeight } from "../../../shared/dom/textarea-autogrow.measure";
export function syncGoalObjectiveHeight(textarea: HTMLTextAreaElement | null): void {
@ -21,8 +22,10 @@ export function observeGoalObjectiveOverflow(content: HTMLElement, onOverflowCha
};
update();
frame = win.requestAnimationFrame(update);
const disposeResizeObserver = observeElementResize(content, update);
return () => {
if (frame) win.cancelAnimationFrame(frame);
disposeResizeObserver();
};
}

View file

@ -1,7 +1,8 @@
import type { Ref, ComponentChild as UiNode } from "preact";
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { listenDomEvent, listenOutsideDomEvent } from "../../../../shared/dom/events.dom";
import { disposeDomListeners, listenDomEvent, listenOutsideDomEvent } from "../../../../shared/dom/events.dom";
import { observeElementResize } from "../../../../shared/dom/resize-observer.measure";
import type { ThreadStreamTextView } from "../../presentation/thread-stream/text-view";
import { THREAD_STREAM_CONTENT_RENDERED_EVENT } from "./content-rendered-event.dom";
import type { TextItemContentContext } from "./context";
@ -21,9 +22,10 @@ export function CollapsibleTextContent({ view, context }: { view: ThreadStreamTe
setOverflows(content.scrollHeight > userDialogueCollapseHeight(content) + 1);
};
const disposeRendered = listenDomEvent(content, THREAD_STREAM_CONTENT_RENDERED_EVENT, update);
const disposeResizeObserver = observeElementResize(content, update);
update();
content.win.requestAnimationFrame(update);
return disposeRendered;
return disposeDomListeners(disposeRendered, disposeResizeObserver);
}, [view.id, view.body, view.renderMode]);
useEffect(() => {

View file

@ -0,0 +1,9 @@
export function observeElementResize(element: HTMLElement, onResize: () => void): () => void {
const ResizeObserverCtor = (element.win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
if (!ResizeObserverCtor) return () => undefined;
const observer = new ResizeObserverCtor(onResize);
observer.observe(element);
return () => {
observer.disconnect();
};
}

View file

@ -82,6 +82,31 @@ describe("GoalPanel", () => {
expect(parent.querySelector<HTMLDetailsElement>(".codex-panel__goal-objective-collapse-details")?.hidden).toBe(true);
});
it("recomputes objective overflow when its rendered size changes", async () => {
const parent = document.createElement("div");
let scrollHeight = 40;
await withResizeObserver(async (notifyResize) => {
await withGoalObjectiveScrollHeight(
() => scrollHeight,
async () => {
await act(async () => {
renderGoal(parent, goal({ objective: "resizable" }), actions());
});
const details = expectPresent(parent.querySelector<HTMLDetailsElement>(".codex-panel__goal-objective-collapse-details"));
expect(details.hidden).toBe(true);
scrollHeight = 100;
await act(async () => {
notifyResize();
});
expect(details.hidden).toBe(false);
},
);
});
});
it("saves inline edits from the editor frame icon while preserving the existing token budget", async () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
@ -324,12 +349,16 @@ async function outsidePointerDown(): Promise<void> {
});
}
async function withGoalObjectiveScrollHeight<T>(scrollHeight: number, fn: () => Promise<T>): Promise<T> {
async function withGoalObjectiveScrollHeight<T>(scrollHeight: number | (() => number), fn: () => Promise<T>): Promise<T> {
const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this.classList.contains("codex-panel__goal-objective") ? scrollHeight : 0;
return this.classList.contains("codex-panel__goal-objective")
? typeof scrollHeight === "function"
? scrollHeight()
: scrollHeight
: 0;
},
});
try {
@ -343,6 +372,30 @@ async function withGoalObjectiveScrollHeight<T>(scrollHeight: number, fn: () =>
}
}
async function withResizeObserver<T>(fn: (notifyResize: () => void) => Promise<T>): Promise<T> {
const original = window.ResizeObserver;
const callbacks: (() => void)[] = [];
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
callbacks.push(() => {
callback([], this);
});
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
window.ResizeObserver = TestResizeObserver;
try {
return await fn(() => {
for (const callback of callbacks) callback();
});
} finally {
window.ResizeObserver = original;
}
}
async function textareaKeydown(
parent: HTMLElement,
options: { key: string; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean; isComposing?: boolean },