From d9024ba983482793e12094363a874c911cbcc424 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 10 Jul 2026 13:14:00 +0900 Subject: [PATCH] Recompute collapsible content overflow after resize --- src/features/chat/ui/goal.dom.ts | 3 + .../ui/thread-stream/text-content.dom.tsx | 6 +- src/shared/dom/resize-observer.measure.ts | 9 +++ tests/features/chat/ui/goal.test.tsx | 57 ++++++++++++++++++- 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/shared/dom/resize-observer.measure.ts diff --git a/src/features/chat/ui/goal.dom.ts b/src/features/chat/ui/goal.dom.ts index 8096d03e..666e434d 100644 --- a/src/features/chat/ui/goal.dom.ts +++ b/src/features/chat/ui/goal.dom.ts @@ -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(); }; } diff --git a/src/features/chat/ui/thread-stream/text-content.dom.tsx b/src/features/chat/ui/thread-stream/text-content.dom.tsx index 545c15dd..f01b6579 100644 --- a/src/features/chat/ui/thread-stream/text-content.dom.tsx +++ b/src/features/chat/ui/thread-stream/text-content.dom.tsx @@ -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(() => { diff --git a/src/shared/dom/resize-observer.measure.ts b/src/shared/dom/resize-observer.measure.ts new file mode 100644 index 00000000..40abd570 --- /dev/null +++ b/src/shared/dom/resize-observer.measure.ts @@ -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(); + }; +} diff --git a/tests/features/chat/ui/goal.test.tsx b/tests/features/chat/ui/goal.test.tsx index a28c3c18..8fe08ea2 100644 --- a/tests/features/chat/ui/goal.test.tsx +++ b/tests/features/chat/ui/goal.test.tsx @@ -82,6 +82,31 @@ describe("GoalPanel", () => { expect(parent.querySelector(".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(".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 { }); } -async function withGoalObjectiveScrollHeight(scrollHeight: number, fn: () => Promise): Promise { +async function withGoalObjectiveScrollHeight(scrollHeight: number | (() => number), fn: () => Promise): Promise { 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(scrollHeight: number, fn: () => } } +async function withResizeObserver(fn: (notifyResize: () => void) => Promise): Promise { + 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 },