perf(ui): keep split divider drag within frame budget (#127840)

* perf(ui): keep split resize work within frames

* test(ui): prove shared divider drag persistence
This commit is contained in:
Vyctor H. Brzezowski
2026-08-22 14:02:53 -03:00
committed by GitHub
parent 70c2be7e6a
commit f70947bf61
5 changed files with 107 additions and 50 deletions
+23 -2
View File
@@ -233,14 +233,18 @@ describe("resizable-divider", () => {
expect([...divider.classList]).toEqual(["dragging"]);
dispatchPointer(document, "pointermove", 220, 7);
expectLastResizeRatio(resized, 0.7);
dispatchPointer(document, "pointermove", 120, 7);
expect(resized).not.toHaveBeenCalled();
await nextFrame();
expectLastResizeRatio(resized, 0.65);
expect(resized).toHaveBeenCalledTimes(1);
expect(resizeEnded).not.toHaveBeenCalled();
dispatchPointer(document, "pointerup", 220, 7);
const endEvent = resizeEnded.mock.lastCall?.[0] as
| CustomEvent<{ splitRatio: number }>
| undefined;
expect(endEvent?.detail).toEqual({ splitRatio: 0.7 });
expect(endEvent?.detail).toEqual({ splitRatio: 0.65 });
expect(resizeEnded).toHaveBeenCalledTimes(1);
expect([...divider.classList]).toEqual([]);
expect(releasePointerCapture).toHaveBeenCalledWith(7);
@@ -264,4 +268,21 @@ describe("resizable-divider", () => {
dispatchPointer(document, "pointermove", 220);
expect(resized).not.toHaveBeenCalled();
});
it("commits the final pointer position when disconnected", async () => {
const divider = await renderDivider();
const resized = vi.fn();
const resizeEnded = vi.fn();
divider.setPointerCapture = vi.fn();
divider.releasePointerCapture = vi.fn();
divider.addEventListener("resize", resized);
divider.addEventListener("resize-end", resizeEnded);
dispatchPointer(divider, "pointerdown", 100);
dispatchPointer(document, "pointermove", 120);
divider.remove();
expectLastResizeRatio(resized, 0.65);
expectLastResizeRatio(resizeEnded, 0.65);
});
});
+50 -27
View File
@@ -22,6 +22,9 @@ class ResizableDivider extends OpenClawLitElement {
private startPosition = 0;
private startRatio = 0;
private dragRatio = 0;
private dragSize = 0;
private dragFrame = 0;
private pendingPosition: number | null = null;
private activePointerId: number | null = null;
static override styles = css`
@@ -114,7 +117,7 @@ class ResizableDivider extends OpenClawLitElement {
super.disconnectedCallback();
this.removeEventListener("pointerdown", this.handlePointerDown);
this.removeEventListener("keydown", this.handleKeyDown);
this.stopDragging();
this.finishDragging(new Event("disconnect"));
}
protected override updated() {
@@ -132,6 +135,10 @@ class ResizableDivider extends OpenClawLitElement {
this.startPosition = this.orientation === "horizontal" ? e.clientY : e.clientX;
this.startRatio = this.currentRatio();
this.dragRatio = this.startRatio;
this.dragSize = this.measureDragSize();
if (this.dragSize <= 0) {
return;
}
this.classList.add("dragging");
this.capturePointer(e.pointerId);
@@ -148,35 +155,21 @@ class ResizableDivider extends OpenClawLitElement {
return;
}
const container = this.parentElement;
if (!container) {
return;
this.pendingPosition = this.orientation === "horizontal" ? e.clientY : e.clientX;
if (!this.dragFrame) {
this.dragFrame = requestAnimationFrame(this.flushPointerMove);
}
};
// Ratio is local to the two adjacent siblings, not the whole container:
// split-view rows/columns hold N panes, and a drag must only redistribute
// the pair this divider sits between. Container size is the 2-child
// fallback (legacy chat sidebar split).
const previousBounds = this.previousElementSibling?.getBoundingClientRect();
const nextBounds = this.nextElementSibling?.getBoundingClientRect();
const containerBounds = container.getBoundingClientRect();
const measuredSize = this.measureSize?.() ?? 0;
const siblingSize =
this.orientation === "horizontal"
? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0)
: (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0);
const containerSize =
measuredSize > 0
? measuredSize
: siblingSize ||
(this.orientation === "horizontal" ? containerBounds.height : containerBounds.width);
if (containerSize <= 0) {
return;
private readonly flushPointerMove = () => {
this.dragFrame = 0;
const position = this.pendingPosition;
this.pendingPosition = null;
if (position !== null) {
this.dragRatio = this.emitResize(
this.startRatio + (position - this.startPosition) / this.dragSize,
);
}
const position = this.orientation === "horizontal" ? e.clientY : e.clientX;
const deltaRatio = (position - this.startPosition) / containerSize;
this.dragRatio = this.emitResize(this.startRatio + deltaRatio);
};
private handleKeyDown = (e: KeyboardEvent) => {
@@ -210,6 +203,11 @@ class ResizableDivider extends OpenClawLitElement {
return;
}
if (this.activePointerId !== null) {
if (this.dragFrame) {
cancelAnimationFrame(this.dragFrame);
this.dragFrame = 0;
}
this.flushPointerMove();
this.emitResizeEnd(this.dragRatio);
}
this.stopDragging();
@@ -222,6 +220,11 @@ class ResizableDivider extends OpenClawLitElement {
}
this.classList.remove("dragging");
this.releaseActivePointer(pointerId);
if (this.dragFrame) {
cancelAnimationFrame(this.dragFrame);
this.dragFrame = 0;
}
this.pendingPosition = null;
window.removeEventListener("pointermove", this.handlePointerMove);
for (const type of DRAG_END_EVENTS) {
@@ -256,6 +259,26 @@ class ResizableDivider extends OpenClawLitElement {
return Math.max(this.minRatio, Math.min(this.maxRatio, value));
}
private measureDragSize() {
const measuredSize = this.measureSize?.() ?? 0;
if (measuredSize > 0) {
return measuredSize;
}
const previousBounds = this.previousElementSibling?.getBoundingClientRect();
const nextBounds = this.nextElementSibling?.getBoundingClientRect();
const siblingSize =
this.orientation === "horizontal"
? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0)
: (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0);
if (siblingSize > 0) {
return siblingSize;
}
const containerBounds = this.parentElement?.getBoundingClientRect();
return this.orientation === "horizontal"
? (containerBounds?.height ?? 0)
: (containerBounds?.width ?? 0);
}
private currentRatio() {
const measuredRatio = this.measureRatio?.();
return measuredRatio !== undefined && Number.isFinite(measuredRatio)
+9 -9
View File
@@ -391,16 +391,16 @@ suite.define(() => {
const divider = page.locator(".board-session-surface__divider");
const dock = page.locator(".board-session-surface__chat");
const dockHeight = () => dock.evaluate((element) => getComputedStyle(element).height);
await divider.focus();
await page.keyboard.press("End");
const dividerBounds = await divider.boundingBox();
expect(dividerBounds).not.toBeNull();
await page.mouse.move(
dividerBounds!.x + dividerBounds!.width / 2,
dividerBounds!.y + dividerBounds!.height / 2,
);
await page.mouse.down();
await page.mouse.move(dividerBounds!.x, dividerBounds!.y - 80);
await page.mouse.up();
await expect.poll(dockHeight).not.toBe("320px");
const clampedHeight = await dockHeight();
// End pins the bottom dock against its clamp, so step back off it: comparing
// a clamped height to itself after reload would pass if persistence broke and
// the dock merely fell back to its minimum.
await page.keyboard.press("ArrowUp");
await page.keyboard.press("ArrowUp");
await expect.poll(dockHeight).not.toBe(clampedHeight);
const persistedHeight = await dockHeight();
expect(persistedHeight).toMatch(/^\d+(?:\.\d+)?px$/u);
+12
View File
@@ -669,6 +669,18 @@ describe("chat page split layout host", () => {
expect(panes.every((pane) => pane.onOpenSplitView === undefined)).toBe(true);
expect(panes[0]?.chatMessagesBySession).toBe(panes[1]?.chatMessagesBySession);
itemAt(dividers, 0, "split divider").dispatchEvent(
new CustomEvent("resize", { detail: { splitRatio: 0.7 } }),
);
await page.updateComplete;
expect(getLayout(page)?.columnWeights[0]).toBeCloseTo(0.7);
expect(getLayout(page)?.columnWeights[1]).toBeCloseTo(0.3);
expect(loadSettings().chatSplitLayout).toBeUndefined();
itemAt(dividers, 0, "split divider").dispatchEvent(new CustomEvent("resize-end"));
expect(loadSettings().chatSplitLayout?.columnWeights[0]).toBeCloseTo(0.7);
expect(loadSettings().chatSplitLayout?.columnWeights[1]).toBeCloseTo(0.3);
itemAt(cells, 0, "split cell").dispatchEvent(new Event("pointerdown"));
await page.updateComplete;
+13 -12
View File
@@ -647,13 +647,16 @@ export class ChatPage extends OpenClawLightDomElement {
.maxRatio=${0.85}
.label=${t("nav.resize")}
@resize=${(event: CustomEvent<{ splitRatio: number }>) => {
const current = this.layout;
if (current) {
this.persistLayout(
resizePanes(current, column.id, paneIndex, event.detail.splitRatio),
);
}
this.layout = this.layout
? resizePanes(
this.layout,
column.id,
paneIndex,
event.detail.splitRatio,
)
: undefined;
}}
@resize-end=${() => this.persistLayout(this.layout)}
></resizable-divider>
`
: nothing}
@@ -672,13 +675,11 @@ export class ChatPage extends OpenClawLightDomElement {
.maxRatio=${0.85}
.label=${t("nav.resize")}
@resize=${(event: CustomEvent<{ splitRatio: number }>) => {
const current = this.layout;
if (current) {
this.persistLayout(
resizeColumns(current, columnIndex, event.detail.splitRatio),
);
}
this.layout = this.layout
? resizeColumns(this.layout, columnIndex, event.detail.splitRatio)
: undefined;
}}
@resize-end=${() => this.persistLayout(this.layout)}
></resizable-divider>
`
: nothing}