From ffd4c255ec5d655a6081a7a00bb9dc9481a70638 Mon Sep 17 00:00:00 2001
From: Finesssee <90105158+Finesssee@users.noreply.github.com>
Date: Sun, 9 Aug 2026 13:27:38 +0700
Subject: [PATCH 1/2] fix(tray): stop fractional-DPI resize loop with explicit
two-state cycle detection (#261)
At 125% DPI the anchored tray flyout can flap between two physical heights
379<->386 px (2-4 Hz): measure setSize layout feedback keeps disagreeing with
the native integer snap. Replace the 2logical deadband with a pure decision
(lib/traySizing.decideTrayHeight) that commits ANY real change (even +5
physical px) but detects a bounded physical A->B->A pair (span <=8, span 7
observed), then retains the larger member and suppresses in-pair flips. Lock
clears on out-of-pair changes, width/min-max/zoom/DPI changes. Applied-frame
reconciliation adopts the candidate as committed when its physical frame is
already on screen. surface maxHeight now follows the RETAINED height
(post-decision), so DOM constraint and window never diverge. No timers, no
observer gating, no broad deadband.
---
.../hooks/useTrayPanelLayout.sizing.test.tsx | 228 +++++++++++++++
.../src/hooks/useTrayPanelLayout.ts | 56 +++-
apps/desktop-tauri/src/lib/traySizing.test.ts | 228 +++++++++++++++
apps/desktop-tauri/src/lib/traySizing.ts | 272 ++++++++++++++++++
4 files changed, 769 insertions(+), 15 deletions(-)
create mode 100644 apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
create mode 100644 apps/desktop-tauri/src/lib/traySizing.test.ts
create mode 100644 apps/desktop-tauri/src/lib/traySizing.ts
diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
new file mode 100644
index 0000000000..e2a881d0d0
--- /dev/null
+++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
@@ -0,0 +1,228 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TrayPanelLayoutOptions } from "./useTrayPanelLayout";
+
+// #261 hook-level proof of the two-state cycle detection:
+// - stable one-way +5-physical-px changes COMMIT (no blanket deadband);
+// - a bounded 679↔686-physical pair (the reporter's 7-px amplitude) is
+// detected once, converges to the LARGER member, and stops committing;
+// - during suppression the surface's max-height tracks the RETAINED window
+// target (never the freshly measured, smaller candidate → no clipping);
+// - genuine growth/shrink outside the pair clears and commits;
+// - anchors fire exactly on real commits (bottom-anchored flow intact).
+const SCALE = 1.25;
+
+const tauriMocks = vi.hoisted(() => ({
+ getWorkAreaRect: vi
+ .fn()
+ .mockResolvedValue({ x: 0, y: 0, width: 1280, height: 900 }),
+ reanchorTrayPanel: vi.fn().mockResolvedValue(undefined),
+ revealTrayPanelWindow: vi.fn().mockResolvedValue(undefined),
+}));
+
+const windowMocks = vi.hoisted(() => ({
+ setSize: vi.fn().mockResolvedValue(undefined),
+ innerSize: vi.fn().mockResolvedValue({ width: 328, height: 420 }),
+ getCurrentWindow: vi.fn(),
+ LogicalSize: vi.fn((width: number, height: number) => ({ width, height })),
+ PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })),
+}));
+
+vi.mock("../lib/tauri", () => tauriMocks);
+vi.mock("@tauri-apps/api/window", () => windowMocks);
+
+import { useTrayPanelLayout } from "./useTrayPanelLayout";
+
+let surface: HTMLElement;
+
+function mountSurface(): void {
+ document.body.innerHTML = [
+ '
",
+ ].join("");
+ surface = document.querySelector(".menu-surface--tray")!;
+}
+
+/** Drive the auto-fit measure: jsdom rects are 0, so scrollHeight dominates →
+ * contentHeight = scrollHeight + 4 (measure pipeline, zoom=1). */
+function setScrollHeight(px: number): void {
+ Object.defineProperty(surface, "scrollHeight", {
+ configurable: true,
+ get: () => px,
+ });
+}
+
+function hookProps(overrides: Partial = {}): TrayPanelLayoutOptions {
+ return {
+ canMeasure: true,
+ denseOverview: false,
+ detailMode: true,
+ layoutKey: "sizing",
+ ...overrides,
+ };
+}
+
+function lastResize(): { width: number; height: number } {
+ const calls = windowMocks.setSize.mock.calls;
+ return calls[calls.length - 1][0];
+}
+
+describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mountSurface();
+ Object.defineProperty(window, "devicePixelRatio", {
+ configurable: true,
+ value: SCALE,
+ });
+ // Win32 applies integer-physical sizes: readback = round(logical * 1.25).
+ windowMocks.setSize.mockImplementation(
+ async (size: { width: number; height: number }) => {
+ windowMocks.innerSize.mockResolvedValue({
+ width: Math.round(size.width * SCALE),
+ height: Math.round(size.height * SCALE),
+ });
+ },
+ );
+ windowMocks.getCurrentWindow.mockReturnValue({
+ setSize: windowMocks.setSize,
+ close: vi.fn().mockResolvedValue(undefined),
+ scaleFactor: vi.fn().mockResolvedValue(SCALE),
+ onResized: vi.fn().mockResolvedValue(() => {}),
+ innerSize: windowMocks.innerSize,
+ } as never);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ /** Nudge a pass and wait for its reveal — the deterministic per-pass
+ * completion signal (no sleeps). */
+ async function nudgePass(
+ result: { current: { requestLayout: () => void } },
+ sh: number,
+ revealCount: number,
+ ): Promise {
+ setScrollHeight(sh);
+ result.current.requestLayout();
+ await waitFor(
+ () =>
+ expect(tauriMocks.revealTrayPanelWindow.mock.calls.length).toBeGreaterThan(
+ revealCount,
+ ),
+ { timeout: 1000 },
+ );
+ return tauriMocks.revealTrayPanelWindow.mock.calls.length;
+ }
+
+ it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => {
+ setScrollHeight(535); // → 539 logical → 674 physical
+ const { result } = renderHook(() => useTrayPanelLayout(hookProps()));
+ await waitFor(() => expect(result.current.layoutReady).toBe(true), {
+ timeout: 3000,
+ });
+ expect(
+ windowMocks.setSize.mock.calls.some(
+ (call) => (call[0] as { height: number }).height === 539,
+ ),
+ ).toBe(true);
+ let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length;
+
+ // (1) Stable one-way +5-physical change COMMITS (no blanket deadband).
+ revealCount = await nudgePass(result, 539, revealCount); // → 543 → 679 phys
+ expect(lastResize()).toEqual({ width: 328, height: 543 });
+ expect(surface.style.maxHeight).toBe("543px");
+
+ // (2) Reporter-class alternation: one commit to 549 (686 phys), then the
+ // 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down.
+ revealCount = await nudgePass(result, 545, revealCount); // → 549 → 686 phys
+ expect(lastResize()).toEqual({ width: 328, height: 549 });
+ expect(surface.style.maxHeight).toBe("549px");
+ const lockedResizes = windowMocks.setSize.mock.calls.length;
+ const lockedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length;
+
+ // Flip-down evidence (measure 539→543): detected, suppressed, and the DOM
+ // constraint stays the RETAINED height — never the smaller candidate.
+ revealCount = await nudgePass(result, 539, revealCount);
+ expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes);
+ expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors);
+ expect(surface.style.maxHeight).toBe("549px");
+
+ // (3) Repeated flips stay suppressed; surface stays at the retained 549.
+ revealCount = await nudgePass(result, 545, revealCount);
+ revealCount = await nudgePass(result, 539, revealCount);
+ expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes);
+ expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors);
+ expect(surface.style.maxHeight).toBe("549px");
+ // Retained window (549 logical) fully contains BOTH measured sides
+ // (543 and 549 candidates) — no clipping by construction.
+ expect(lastResize()).toEqual({ width: 328, height: 549 });
+
+ // (4) Real growth outside the pair clears the lock and commits once.
+ revealCount = await nudgePass(result, 700, revealCount); // → 704 → 880 phys
+ expect(lastResize()).toEqual({ width: 328, height: 704 });
+ expect(surface.style.maxHeight).toBe("704px");
+
+ // Real shrink commits once.
+ revealCount = await nudgePass(result, 410, revealCount); // → clamp 420 → 525 phys
+ expect(lastResize()).toEqual({ width: 328, height: 420 });
+ expect(surface.style.maxHeight).toBe("420px");
+
+ // (5) No blanket absorption: a stable 1-physical-px change still commits.
+ revealCount = await nudgePass(result, 417, revealCount); // → 421 → 526 phys
+ expect(lastResize()).toEqual({ width: 328, height: 421 });
+ expect(surface.style.maxHeight).toBe("421px");
+ });
+
+ it("reconciles to the applied physical frame after an OS snap (no churn, no cycle)", async () => {
+ // Deliberate 5-physical snap: requesting 539 logical (→674 phys) yields an
+ // innerSize readback of 669.
+ windowMocks.setSize.mockImplementation(
+ async (size: { width: number; height: number }) => {
+ windowMocks.innerSize.mockResolvedValue({
+ width: Math.round(size.width * SCALE),
+ height: size.height === 539 ? 669 : Math.round(size.height * SCALE),
+ });
+ },
+ );
+
+ setScrollHeight(535); // → 539 target (674 phys requested) → applied 669
+ const { result } = renderHook(() => useTrayPanelLayout(hookProps()));
+ await waitFor(() => expect(result.current.layoutReady).toBe(true), {
+ timeout: 3000,
+ });
+ expect(
+ windowMocks.setSize.mock.calls.some(
+ (call) => (call[0] as { height: number }).height === 539,
+ ),
+ ).toBe(true);
+ const snappedResizes = windowMocks.setSize.mock.calls.length;
+ const snappedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length;
+ let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length;
+
+ // Candidate 535 (→669 phys) equals the APPLIED frame while the recorded
+ // target says 674: suppress (no setSize/reanchor), adopt the candidate.
+ // The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B
+ // detector cannot fire here.
+ revealCount = await nudgePass(result, 531, revealCount); // → 535 → 669 phys
+ expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes);
+ expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors);
+ expect(surface.style.maxHeight).toBe("535px");
+
+ // Identical next pass: now exact same-frame stable — still zero churn.
+ revealCount = await nudgePass(result, 531, revealCount);
+ expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes);
+ expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors);
+ expect(surface.style.maxHeight).toBe("535px");
+
+ // Recovery: a real +5-physical change from the reconciled frame commits.
+ revealCount = await nudgePass(result, 535, revealCount); // → 539 → 674 phys
+ expect(lastResize()).toEqual({ width: 328, height: 539 });
+ expect(surface.style.maxHeight).toBe("539px");
+ });
+});
diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts
index 65f265e206..7120cf16f7 100644
--- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts
+++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts
@@ -9,6 +9,12 @@ import {
reanchorTrayPanel,
revealTrayPanelWindow,
} from "../lib/tauri";
+import {
+ decideTrayHeight,
+ EMPTY_AUTOFIT_STATE,
+ recordAutoFitCommit,
+ type TrayAutoFitState,
+} from "../lib/traySizing";
const TRAY_WIDTH = 328;
const TRAY_MAX_MEASURE_HEIGHT = 920;
@@ -77,11 +83,10 @@ export function useTrayPanelLayout({
// compounded a per-open size growth.
const lastSizeRef = useRef<{ width: number; height: number } | null>(null);
const programmaticInFlightRef = useRef(0);
- // Auto-fit tracks its last LOGICAL target separately (lastSizeRef is physical)
- // so its "did the content size change?" check stays in content pixels.
- const autoFitLogicalRef = useRef<{ width: number; height: number } | null>(
- null,
- );
+ // Auto-fit sizing decision state (committed frame + one-frame history +
+ // learned oscillation pair) — all frame logic lives in lib/traySizing so
+ // the #261 cycle detection stays pure and directly testable.
+ const sizingStateRef = useRef(EMPTY_AUTOFIT_STATE);
const fixedSizeRef = useRef(fixedSize);
useEffect(() => {
fixedSizeRef.current = fixedSize;
@@ -268,7 +273,12 @@ export function useTrayPanelLayout({
programmaticInFlightRef.current += 1;
try {
if (!layoutReadyRef.current) {
- autoFitLogicalRef.current = { width: TRAY_WIDTH, height: minHeight };
+ sizingStateRef.current = recordAutoFitCommit(
+ sizingStateRef.current,
+ TRAY_WIDTH,
+ minHeight,
+ window.devicePixelRatio,
+ );
await applySize(new LogicalSize(TRAY_WIDTH, minHeight));
}
@@ -308,17 +318,33 @@ export function useTrayPanelLayout({
contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4;
const height = Math.min(Math.max(contentHeight, minHeight), maxHeight);
- surface.style.maxHeight = `${height}px`;
+
+ // #261: two-state cycle detection on physical targets. Normal rule
+ // commits ANY real change (even +5 physical px); only exact same-
+ // frame equality is a no-op, and a bounded A→B→A pair (span ≤8
+ // physical, the reporter's 7) locks onto its larger member. The DOM
+ // constraint is set AFTER the decision from the RETAINED height, so
+ // surface and window never diverge.
+ const decision = decideTrayHeight(
+ {
+ measuredHeight: height,
+ expectedWidth: TRAY_WIDTH,
+ minHeight,
+ maxHeight,
+ // WebView layout px ↔ Win32 physical px ratio; CSS zoom does not
+ // affect it (zoom is already in `height` via scaledContentHeight).
+ scaleFactor: window.devicePixelRatio,
+ zoom,
+ lastAppliedPhysicalHeight: lastSizeRef.current?.height ?? null,
+ },
+ sizingStateRef.current,
+ );
+ sizingStateRef.current = decision.state;
+ surface.style.maxHeight = `${decision.height}px`;
committedHeight = true;
- const previousSize = autoFitLogicalRef.current;
- const shouldResize =
- previousSize === null ||
- previousSize.width !== TRAY_WIDTH ||
- Math.abs(previousSize.height - height) > 2;
- if (shouldResize) {
- autoFitLogicalRef.current = { width: TRAY_WIDTH, height };
- await applySize(new LogicalSize(TRAY_WIDTH, height));
+ if (decision.commit) {
+ await applySize(new LogicalSize(TRAY_WIDTH, decision.height));
await Promise.resolve(reanchorTrayPanel()).catch(() => {});
}
diff --git a/apps/desktop-tauri/src/lib/traySizing.test.ts b/apps/desktop-tauri/src/lib/traySizing.test.ts
new file mode 100644
index 0000000000..ff990acef3
--- /dev/null
+++ b/apps/desktop-tauri/src/lib/traySizing.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from "vitest";
+import {
+ decideTrayHeight,
+ EMPTY_AUTOFIT_STATE,
+ recordAutoFitCommit,
+ TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX,
+ type TrayAutoFitState,
+ type TraySizingInput,
+} from "./traySizing";
+
+const TRAY_WIDTH = 328;
+const MIN = 420;
+const MAX = 920;
+
+function input(overrides: Partial = {}): TraySizingInput {
+ return {
+ measuredHeight: 500,
+ expectedWidth: TRAY_WIDTH,
+ minHeight: MIN,
+ maxHeight: MAX,
+ scaleFactor: 1,
+ zoom: 1,
+ lastAppliedPhysicalHeight: null,
+ ...overrides,
+ };
+}
+
+interface ReplayStep {
+ measured: number;
+ reason: string;
+ commit: boolean;
+ height: number;
+}
+
+/** Replay a candidate stream the way the hook does: a commit updates the
+ * returned state AND the applied-physical readback (Win32 applies
+ * round(height*sf)); a suppression changes neither. */
+function replay(
+ scaleFactor: number,
+ candidates: number[],
+ overrides: Partial = {},
+): { steps: ReplayStep[]; state: TrayAutoFitState } {
+ let state = EMPTY_AUTOFIT_STATE;
+ let applied: number | null = null;
+ const steps: ReplayStep[] = [];
+ for (const measured of candidates) {
+ const d = decideTrayHeight(
+ input({ measuredHeight: measured, scaleFactor, lastAppliedPhysicalHeight: applied, ...overrides }),
+ state,
+ );
+ state = d.state;
+ if (d.commit) applied = Math.round(d.height * scaleFactor);
+ steps.push({ measured, reason: d.reason, commit: d.commit, height: d.height });
+ }
+ return { steps, state };
+}
+
+function commitIndexes(steps: ReplayStep[]): number[] {
+ return steps.flatMap((s, i) => (s.commit ? [i] : []));
+}
+
+describe("decideTrayHeight (#261 two-state cycle detection)", () => {
+ it("commits the initial fit and clamps the measured height", () => {
+ const d = decideTrayHeight(input({ measuredHeight: 539 }), EMPTY_AUTOFIT_STATE);
+ expect(d).toMatchObject({ commit: true, reason: "initial", height: 539 });
+ expect(decideTrayHeight(input({ measuredHeight: 50 }), EMPTY_AUTOFIT_STATE).height).toBe(MIN);
+ expect(decideTrayHeight(input({ measuredHeight: 5000 }), EMPTY_AUTOFIT_STATE).height).toBe(MAX);
+ });
+
+ it("normal rule: every stable ONE-WAY small step commits (+5 physical each)", () => {
+ // 480→484→488→492 logical @1.25 = 600→605→610→615 physical. NO blanket
+ // deadband may absorb these: each is a real, stable change.
+ const { steps } = replay(1.25, [480, 484, 488, 492]);
+ expect(commitIndexes(steps)).toEqual([0, 1, 2, 3]);
+ expect(steps.every((s) => s.reason === "initial" || s.reason === "commit")).toBe(true);
+ });
+
+ it("suppresses only exact same quantized frame (no neighborhood)", () => {
+ const { steps } = replay(1.25, [480, 480, 481]);
+ // 481→601.25≠600: even +1 physical px commits.
+ expect(commitIndexes(steps)).toEqual([0, 2]);
+ expect(steps[1]).toMatchObject({ commit: false, reason: "same-frame", height: 480 });
+ });
+
+ it("reporter pair: 379↔386 physical alternation detects once, converges to the LARGER member", () => {
+ // 303.2↔308.8 logical @1.25 = exactly 379↔386 physical (reporter, issue #261).
+ expect(Math.round(303.2 * 1.25)).toBe(379);
+ expect(Math.round(308.8 * 1.25)).toBe(386);
+ const { steps } = replay(1.25, [303.2, 308.8, 303.2, 303.2, 308.8, 303.2], { minHeight: 200 });
+ expect(commitIndexes(steps)).toEqual([0, 1]);
+ expect(steps[2]).toMatchObject({ commit: false, reason: "cycle-converge", height: 308.8 });
+ for (const s of steps.slice(2)) {
+ expect(s.commit).toBe(false);
+ expect(s.reason).toMatch(/cycle/);
+ // Retained height is ALWAYS the larger member — the window fully
+ // contains the surface from either measured side (308.8 ≥ 303.2).
+ expect(s.height).toBe(308.8);
+ }
+ });
+
+ it("cycle detected below current position commits ONCE upward, then locks", () => {
+ // A=500(625) → B=495(619) → A: committed is the lo member, so the detector
+ // commits once to the larger (safe) member, then suppresses all flips.
+ const { steps } = replay(1.25, [500, 495, 500, 495, 500, 495]);
+ expect(commitIndexes(steps)).toEqual([0, 1, 2]);
+ expect(steps[2]).toMatchObject({ commit: true, reason: "cycle-converge", height: 500 });
+ expect(steps[3]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 500 });
+ expect(steps[4]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 500 });
+ });
+
+ it("does NOT classify a lone A→B small change as oscillation", () => {
+ const { steps, state } = replay(1.25, [303.2, 308.8, 308.8, 308.8], { minHeight: 200 });
+ expect(commitIndexes(steps)).toEqual([0, 1]);
+ expect(steps[2].reason).toBe("same-frame");
+ expect(state.cycle).toBeNull();
+ });
+
+ it("does NOT cycle-detect pairs above the physical span cap", () => {
+ expect(TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX).toBe(8);
+ // A=300→300, B=310→310 at sf=1: span 10 > 8 → genuine large flip, commits back.
+ const { steps, state } = replay(1, [300, 310, 300], { minHeight: 200 });
+ expect(commitIndexes(steps)).toEqual([0, 1, 2]);
+ expect(state.cycle).toBeNull();
+ });
+
+ it("clears the lock and commits the moment a candidate lands OUTSIDE the learned pair", () => {
+ const { steps, state } = replay(1.25, [303.2, 308.8, 303.2, 330], { minHeight: 200 });
+ expect(commitIndexes(steps)).toEqual([0, 1, 3]);
+ expect(steps[3]).toMatchObject({ commit: true, height: 330 });
+ expect(state.cycle).toBeNull();
+ // And values near—but not equal to—the old pair never stay suppressed.
+ const { steps: near } = replay(1.25, [303.2, 308.8, 303.2, 306, 306], { minHeight: 200 });
+ expect(commitIndexes(near)).toEqual([0, 1, 3]);
+ });
+
+ it("clears the lock on zoom change, DPI change, layout-class (min/max) change, and width change", () => {
+ const base: number[] = [303.2, 308.8, 303.2];
+ const locked = replay(1.25, base, { minHeight: 200 });
+ expect(locked.state.cycle).not.toBeNull();
+
+ // zoom 1 → 1.5 with an in-pair candidate: lock invalidated.
+ const zoomed = decideTrayHeight(
+ input({ measuredHeight: 308.8, scaleFactor: 1.25, zoom: 1.5, minHeight: 200 }),
+ locked.state,
+ );
+ expect(zoomed.state.cycle).toBeNull();
+
+ // DPI 1.25 → 1.0: physical frames incomparable → full reset, fresh fit.
+ const dpi = decideTrayHeight(input({ measuredHeight: 308.8, scaleFactor: 1, minHeight: 200 }), locked.state);
+ expect(dpi).toMatchObject({ commit: true, reason: "initial" });
+ expect(dpi.state.cycle).toBeNull();
+
+ // Layout class: min 200 → 420 with an outside-pair candidate → cleared + commit.
+ const klass = decideTrayHeight(input({ measuredHeight: 500, scaleFactor: 1.25, minHeight: 420 }), locked.state);
+ expect(klass.state.cycle).toBeNull();
+ expect(klass.commit).toBe(true);
+
+ // Width class change commits and clears.
+ const wide = decideTrayHeight(input({ measuredHeight: 308.8, scaleFactor: 1.25, minHeight: 200, expectedWidth: 400 }), locked.state);
+ expect(wide).toMatchObject({ commit: true, reason: "width" });
+ expect(wide.state.cycle).toBeNull();
+ });
+
+ it("re-learns a pair only on fresh A→B→A evidence after a clear", () => {
+ // Lock {379,386}, then a genuine stable move to 500 (lock cleared); a NEW
+ // 500↔505 flip forms its own pair and converges again.
+ const seq = replay(1.25, [303.2, 308.8, 303.2, 500, 505, 500, 500, 505], { minHeight: 200 });
+ expect(commitIndexes(seq.steps)).toEqual([0, 1, 3, 4]);
+ expect(seq.steps[5]).toMatchObject({ reason: "cycle-converge", height: 505 });
+ expect(seq.steps[7]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 505 });
+ });
+
+ it("reconciles committed state to the EXACT physical frame Win32 applied (readback)", () => {
+ // History: min fit 420 (→525), then a real 480 commit (→600 target) whose
+ // apply the OS snapped to applied=595. A 476 candidate (→595) equals the
+ // on-screen frame: no setSize, and the committed frame is REPLACED by the
+ // candidate's own {476,595} — the DOM constraint and future comparisons
+ // now describe reality — while `prior` is left untouched (no manufactured
+ // transition feeding the cycle detector).
+ let state = recordAutoFitCommit(EMPTY_AUTOFIT_STATE, TRAY_WIDTH, 420, 1.25);
+ state = recordAutoFitCommit(state, TRAY_WIDTH, 480, 1.25);
+ expect(state.prior).toMatchObject({ height: 420, physical: 525 });
+
+ const d = decideTrayHeight(
+ input({ measuredHeight: 476, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595 }), // 476→595
+ state,
+ );
+ expect(d).toMatchObject({ commit: false, reason: "applied-frame", height: 476 });
+ expect(d.state.committed).toMatchObject({
+ width: TRAY_WIDTH,
+ height: 476,
+ physical: 595,
+ });
+ expect(d.state.prior).toMatchObject({ height: 420, physical: 525 });
+ expect(d.state.cycle).toBeNull();
+
+ // The very next identical candidate is now an exact same-frame no-op.
+ const again = decideTrayHeight(
+ input({ measuredHeight: 476, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595 }),
+ d.state,
+ );
+ expect(again).toMatchObject({ commit: false, reason: "same-frame", height: 476 });
+
+ // …but only for EXACT equality: 596 physical is a real +1 change.
+ const d2 = decideTrayHeight(
+ input({ measuredHeight: 476.8, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595, minHeight: 200 }),
+ state,
+ );
+ expect(Math.round(476.8 * 1.25)).toBe(596);
+ expect(d2.commit).toBe(true);
+ });
+
+ it("recordAutoFitCommit shifts history for hook-driven fits (min-fit seed)", () => {
+ let s = recordAutoFitCommit(EMPTY_AUTOFIT_STATE, TRAY_WIDTH, 420, 1.25);
+ expect(s.committed).toMatchObject({ width: TRAY_WIDTH, height: 420, physical: 525 });
+ expect(s.prior).toBeNull();
+ s = recordAutoFitCommit(s, TRAY_WIDTH, 200, 1.25);
+ expect(s.committed).toMatchObject({ height: 200, physical: 250 });
+ expect(s.prior).toMatchObject({ height: 420, physical: 525 });
+ });
+
+ it("zoom is measure-space: alternation around a zoom-scaled size still locks", () => {
+ // content 500 × zoom 1.5 = 750 measured; pair 750↔754 (Δ5) still detects.
+ const { steps } = replay(1, [750, 754, 750, 754], { zoom: 1.5 });
+ expect(commitIndexes(steps)).toEqual([0, 1]);
+ expect(steps[3]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 754 });
+ });
+});
diff --git a/apps/desktop-tauri/src/lib/traySizing.ts b/apps/desktop-tauri/src/lib/traySizing.ts
new file mode 100644
index 0000000000..10a357535e
--- /dev/null
+++ b/apps/desktop-tauri/src/lib/traySizing.ts
@@ -0,0 +1,272 @@
+/**
+ * Pure auto-fit sizing decision for the anchored tray flyout (#261).
+ *
+ * Problem: on fractional-DPI setups (reporter: Win11 25H2 at 125% scale,
+ * AppliedDPI 120) the transparent borderless flyout alternated natively
+ * between 379 and 386 physical px (≈5.6 logical px) at 2–4 Hz: a bounded
+ * two-position measure↔setSize↔layout feedback loop. The exact OS driver
+ * is unconfirmed (a same-layout Chromium sandbox at deviceScaleFactor 1.25
+ * converges), so this targets the demonstrated FEEDBACK CLASS, not a guess
+ * at the OS cause: explicit two-state cycle detection on physical targets.
+ *
+ * Policy (narrow):
+ * - NORMAL RULE: any candidate whose quantized PHYSICAL height differs
+ * from the committed frame commits — including a stable one-way +5 px.
+ * Suppression happens ONLY for exact same-frame equality (a genuine
+ * no-op) — there is deliberately NO neighborhood deadband, so small real
+ * deltas are never absorbed.
+ * - CYCLE DETECTION: history of the last two committed physical targets.
+ * A candidate returning to the previous-previous frame (A→B→A) with pair
+ * span ≤ TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX (reporter observed 7; cap 8)
+ * is an oscillation pair. A lone A→B small change is NOT classified as
+ * oscillation — it commits normally.
+ * - Once a pair is learned, the LARGER member is retained (window fully
+ * contains the surface, no clipping) and candidates equal to EITHER pair
+ * member are suppressed until something outside the pair arrives.
+ * - The lock clears on: a candidate outside the pair (real change), a
+ * width change, a min/max (layout-class) change, a zoom change, or a DPI
+ * (scaleFactor) change — so unrelated nearby values are never suppressed
+ * indefinitely.
+ *
+ * No timers, no debounce, no observer gating: measurement always runs;
+ * only setSize/re-anchor commits are decided here.
+ */
+
+/** Max span (physical px) of a recognized two-state feedback pair. Reporter
+ * evidence: 7 physical px at 125% scale; capped at 8. */
+export const TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX = 8;
+
+export interface TrayFrame {
+ /** Logical-px height committed/applied to the window. */
+ height: number;
+ /** Quantized physical height: round(height * scaleFactor) at commit. */
+ physical: number;
+}
+
+interface CycleLock {
+ lo: TrayFrame;
+ hi: TrayFrame;
+ scaleFactor: number;
+ zoom: number;
+ minHeight: number;
+ maxHeight: number;
+}
+
+/** Full decision state; owned by the hook in a ref, threaded pure. */
+export interface TrayAutoFitState {
+ /** Last committed frame (null before the first fit). */
+ committed: (TrayFrame & { width: number }) | null;
+ /** The frame committed BEFORE `committed` (the A in A→B→A). */
+ prior: TrayFrame | null;
+ /** Learned oscillation pair after two-state detection. */
+ cycle: CycleLock | null;
+ /** Scale factor all physical values above were computed with. */
+ scaleFactor: number | null;
+}
+
+export const EMPTY_AUTOFIT_STATE: TrayAutoFitState = {
+ committed: null,
+ prior: null,
+ cycle: null,
+ scaleFactor: null,
+};
+
+export interface TraySizingInput {
+ /** Measured content height, logical px, post zoom-scale, pre clamp. */
+ measuredHeight: number;
+ /** Logical content width the flyout always carries (TRAY_WIDTH). */
+ expectedWidth: number;
+ minHeight: number;
+ maxHeight: number;
+ /** Win32 DPI ratio (window.devicePixelRatio), e.g. 1.25 at 125%. */
+ scaleFactor: number;
+ /** Active tray zoom (CSS zoom factor already applied to the measure). */
+ zoom: number;
+ /** Physical height Win32 ACTUALLY applied after our last resize
+ * (`innerSize()` readback); used only for exact-frame equality. */
+ lastAppliedPhysicalHeight: number | null;
+}
+
+export type TrayDecisionReason =
+ | "initial"
+ | "width"
+ | "commit"
+ | "same-frame"
+ | "applied-frame"
+ | "cycle-converge"
+ | "cycle-suppress";
+
+export interface TrayAutoFitDecision {
+ /** Logical height the DOM constraint AND any window apply must use.
+ * On suppression this is ALWAYS the retained committed height, never
+ * the freshly measured candidate. */
+ height: number;
+ commit: boolean;
+ reason: TrayDecisionReason;
+ state: TrayAutoFitState;
+}
+
+/** Record a commit the decision function did not order (initial min fit). */
+export function recordAutoFitCommit(
+ state: TrayAutoFitState,
+ width: number,
+ height: number,
+ scaleFactor: number,
+): TrayAutoFitState {
+ const frame = { height, physical: Math.round(height * scaleFactor) };
+ return {
+ committed: { ...frame, width },
+ prior: state.committed
+ ? { height: state.committed.height, physical: state.committed.physical }
+ : state.prior,
+ cycle: state.cycle,
+ scaleFactor,
+ };
+}
+
+export function decideTrayHeight(
+ input: TraySizingInput,
+ prevState: TrayAutoFitState,
+): TrayAutoFitDecision {
+ const sf =
+ Number.isFinite(input.scaleFactor) && input.scaleFactor > 0
+ ? input.scaleFactor
+ : 1;
+ const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
+ const height = Math.min(
+ Math.max(input.measuredHeight, input.minHeight),
+ input.maxHeight,
+ );
+ const candidatePhysical = Math.round(height * sf);
+
+ // DPI changed: physical frames are incomparable — reset all history.
+ let state =
+ prevState.scaleFactor !== null && prevState.scaleFactor !== sf
+ ? { ...EMPTY_AUTOFIT_STATE, scaleFactor: sf }
+ : { ...prevState, scaleFactor: sf };
+
+ // Validate or drop the learned cycle before anything consults it.
+ if (state.cycle) {
+ const lock = state.cycle;
+ const contextUnchanged =
+ lock.scaleFactor === sf &&
+ lock.zoom === zoom &&
+ lock.minHeight === input.minHeight &&
+ lock.maxHeight === input.maxHeight &&
+ state.committed !== null &&
+ state.committed.width === input.expectedWidth;
+ const inPair =
+ candidatePhysical === lock.lo.physical ||
+ candidatePhysical === lock.hi.physical;
+ if (!contextUnchanged) {
+ state = { ...state, cycle: null };
+ } else if (inPair) {
+ // Still flipping inside the learned pair: retain the LARGER member —
+ // the window fully contains the surface in either measure state.
+ return { height: lock.hi.height, commit: false, reason: "cycle-suppress", state };
+ } else {
+ // Real change outside the pair: unlock and fall through to the
+ // normal rule with history intact.
+ state = { ...state, cycle: null };
+ }
+ }
+
+ if (state.committed === null) {
+ return {
+ height,
+ commit: true,
+ reason: "initial",
+ state: {
+ committed: { height, physical: candidatePhysical, width: input.expectedWidth },
+ prior: null,
+ cycle: null,
+ scaleFactor: sf,
+ },
+ };
+ }
+
+ const committed = state.committed;
+ const pushCommit = (): TrayAutoFitState => ({
+ committed: { height, physical: candidatePhysical, width: input.expectedWidth },
+ prior: { height: committed.height, physical: committed.physical },
+ cycle: null,
+ scaleFactor: sf,
+ });
+
+ if (committed.width !== input.expectedWidth) {
+ return { height, commit: true, reason: "width", state: pushCommit() };
+ }
+
+ // Exact same quantized frame as committed: a genuine no-op (for integer
+ // logical measures at sf ≥ 1, physical equality ⇔ logical equality).
+ if (candidatePhysical === committed.physical) {
+ return { height: committed.height, commit: false, reason: "same-frame", state };
+ }
+
+ // A→B→A bounded two-state detection: candidate returns to the frame we
+ // committed before `committed`, at a span within the observed feedback
+ // amplitude. A lone A→B never reaches this branch.
+ const prior = state.prior;
+ if (
+ prior !== null &&
+ candidatePhysical === prior.physical &&
+ Math.abs(candidatePhysical - committed.physical) <= TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX
+ ) {
+ const loFrame = prior.physical <= committed.physical ? prior : committed;
+ const hiFrame = prior.physical <= committed.physical ? committed : prior;
+ const cycle: CycleLock = {
+ lo: loFrame,
+ hi: hiFrame,
+ scaleFactor: sf,
+ zoom,
+ minHeight: input.minHeight,
+ maxHeight: input.maxHeight,
+ };
+ if (committed.physical === hiFrame.physical) {
+ // Already at the larger member: hold it, apply nothing.
+ return {
+ height: hiFrame.height,
+ commit: false,
+ reason: "cycle-converge",
+ state: { ...state, cycle },
+ };
+ }
+ // Retain the LARGER member with one convergence commit, then lock.
+ return {
+ height,
+ commit: true,
+ reason: "cycle-converge",
+ state: {
+ committed: { height, physical: candidatePhysical, width: input.expectedWidth },
+ prior: { height: committed.height, physical: committed.physical },
+ cycle,
+ scaleFactor: sf,
+ },
+ };
+ }
+
+ // Idempotence anchored in reality: the candidate's physical frame IS what
+ // Win32 currently shows (snap/clamp made the applied size differ from the
+ // recorded target). Re-applying would be a rect no-op, so suppress the
+ // setSize — and reconcile state to reality: the committed frame becomes
+ // the candidate's (logical height + physical), so the DOM constraint and
+ // any later comparisons use what is actually on screen. `prior` is left
+ // untouched: this is not a committed transition, so it must not feed the
+ // cycle detector's A→B→A evidence.
+ if (
+ input.lastAppliedPhysicalHeight !== null &&
+ candidatePhysical === input.lastAppliedPhysicalHeight
+ ) {
+ return {
+ height,
+ commit: false,
+ reason: "applied-frame",
+ state: {
+ ...state,
+ committed: { height, physical: candidatePhysical, width: input.expectedWidth },
+ },
+ };
+ }
+
+ return { height, commit: true, reason: "commit", state: pushCommit() };
+}
From 25e6fec4a6ca161106fbd7a6d0041986c6496394 Mon Sep 17 00:00:00 2001
From: Finesssee <90105158+Finesssee@users.noreply.github.com>
Date: Sun, 9 Aug 2026 13:35:26 +0700
Subject: [PATCH 2/2] test(tray): harden #261 convergence-test sequencing with
per-pass markers
The CI runner's scheduling can misalign a nudge with its pass (reveal
count increments are not pass-unique). Wait on the surface maxHeight
marker each pass sets instead; identical markers fall back to reveal+1.
---
.../hooks/useTrayPanelLayout.sizing.test.tsx | 60 +++++++++++--------
1 file changed, 36 insertions(+), 24 deletions(-)
diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
index e2a881d0d0..8542eb6a11 100644
--- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
+++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx
@@ -10,6 +10,10 @@ import type { TrayPanelLayoutOptions } from "./useTrayPanelLayout";
// target (never the freshly measured, smaller candidate → no clipping);
// - genuine growth/shrink outside the pair clears and commits;
// - anchors fire exactly on real commits (bottom-anchored flow intact).
+//
+// Sequencing instead of sleeps: every completed pass marks the surface's
+// max-height (it is set on EVERY pass from the decision, suppressed or not),
+// so each nudge waits for its own marker value.
const SCALE = 1.25;
const tauriMocks = vi.hoisted(() => ({
@@ -71,6 +75,10 @@ function lastResize(): { width: number; height: number } {
return calls[calls.length - 1][0];
}
+function hookResult(result: unknown): { current: { requestLayout: () => void } } {
+ return result as { current: { requestLayout: () => void } };
+}
+
describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -101,23 +109,29 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
document.body.innerHTML = "";
});
- /** Nudge a pass and wait for its reveal — the deterministic per-pass
- * completion signal (no sleeps). */
+ /** Nudge a pass and wait until IT marks the surface with `expectedMarker`
+ * (maxHeight is assigned on every pass → deterministic per-pass signal,
+ * immune to nudge-vs-pass alignment races). Returns nothing; callers assert
+ * setSize/anchor deltas against counts they snapshotted before the nudge. */
async function nudgePass(
- result: { current: { requestLayout: () => void } },
+ result: unknown,
sh: number,
- revealCount: number,
- ): Promise {
+ expectedMarker: string,
+ ): Promise {
+ const r = hookResult(result);
+ const alreadyMarked = surface.style.maxHeight === expectedMarker;
+ const revealsAtNudge = tauriMocks.revealTrayPanelWindow.mock.calls.length;
setScrollHeight(sh);
- result.current.requestLayout();
+ r.current.requestLayout();
await waitFor(
() =>
- expect(tauriMocks.revealTrayPanelWindow.mock.calls.length).toBeGreaterThan(
- revealCount,
- ),
- { timeout: 1000 },
+ alreadyMarked
+ ? expect(
+ tauriMocks.revealTrayPanelWindow.mock.calls.length,
+ ).toBeGreaterThan(revealsAtNudge)
+ : expect(surface.style.maxHeight).toBe(expectedMarker),
+ { timeout: 3000 },
);
- return tauriMocks.revealTrayPanelWindow.mock.calls.length;
}
it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => {
@@ -131,16 +145,15 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
(call) => (call[0] as { height: number }).height === 539,
),
).toBe(true);
- let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length;
// (1) Stable one-way +5-physical change COMMITS (no blanket deadband).
- revealCount = await nudgePass(result, 539, revealCount); // → 543 → 679 phys
+ await nudgePass(result, 539, "543px"); // → 543 → 679 phys
expect(lastResize()).toEqual({ width: 328, height: 543 });
expect(surface.style.maxHeight).toBe("543px");
// (2) Reporter-class alternation: one commit to 549 (686 phys), then the
// 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down.
- revealCount = await nudgePass(result, 545, revealCount); // → 549 → 686 phys
+ await nudgePass(result, 545, "549px"); // → 549 → 686 phys
expect(lastResize()).toEqual({ width: 328, height: 549 });
expect(surface.style.maxHeight).toBe("549px");
const lockedResizes = windowMocks.setSize.mock.calls.length;
@@ -148,14 +161,14 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
// Flip-down evidence (measure 539→543): detected, suppressed, and the DOM
// constraint stays the RETAINED height — never the smaller candidate.
- revealCount = await nudgePass(result, 539, revealCount);
+ await nudgePass(result, 539, "549px");
expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes);
expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors);
expect(surface.style.maxHeight).toBe("549px");
// (3) Repeated flips stay suppressed; surface stays at the retained 549.
- revealCount = await nudgePass(result, 545, revealCount);
- revealCount = await nudgePass(result, 539, revealCount);
+ await nudgePass(result, 545, "549px");
+ await nudgePass(result, 539, "549px");
expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes);
expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors);
expect(surface.style.maxHeight).toBe("549px");
@@ -164,17 +177,17 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
expect(lastResize()).toEqual({ width: 328, height: 549 });
// (4) Real growth outside the pair clears the lock and commits once.
- revealCount = await nudgePass(result, 700, revealCount); // → 704 → 880 phys
+ await nudgePass(result, 700, "704px"); // → 704 → 880 phys
expect(lastResize()).toEqual({ width: 328, height: 704 });
expect(surface.style.maxHeight).toBe("704px");
// Real shrink commits once.
- revealCount = await nudgePass(result, 410, revealCount); // → clamp 420 → 525 phys
+ await nudgePass(result, 410, "420px"); // → clamp 420 → 525 phys
expect(lastResize()).toEqual({ width: 328, height: 420 });
expect(surface.style.maxHeight).toBe("420px");
// (5) No blanket absorption: a stable 1-physical-px change still commits.
- revealCount = await nudgePass(result, 417, revealCount); // → 421 → 526 phys
+ await nudgePass(result, 417, "421px"); // → 421 → 526 phys
expect(lastResize()).toEqual({ width: 328, height: 421 });
expect(surface.style.maxHeight).toBe("421px");
});
@@ -203,25 +216,24 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => {
).toBe(true);
const snappedResizes = windowMocks.setSize.mock.calls.length;
const snappedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length;
- let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length;
// Candidate 535 (→669 phys) equals the APPLIED frame while the recorded
// target says 674: suppress (no setSize/reanchor), adopt the candidate.
// The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B
// detector cannot fire here.
- revealCount = await nudgePass(result, 531, revealCount); // → 535 → 669 phys
+ await nudgePass(result, 531, "535px"); // → 535 → 669 phys
expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes);
expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors);
expect(surface.style.maxHeight).toBe("535px");
// Identical next pass: now exact same-frame stable — still zero churn.
- revealCount = await nudgePass(result, 531, revealCount);
+ await nudgePass(result, 531, "535px");
expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes);
expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors);
expect(surface.style.maxHeight).toBe("535px");
// Recovery: a real +5-physical change from the reconciled frame commits.
- revealCount = await nudgePass(result, 535, revealCount); // → 539 → 674 phys
+ await nudgePass(result, 535, "539px"); // → 539 → 674 phys
expect(lastResize()).toEqual({ width: 328, height: 539 });
expect(surface.style.maxHeight).toBe("539px");
});