Skip to content

fix(tray): stop fractional-DPI tray-flyout resize oscillation via two-state cycle detection (#261) - #272

Merged
Finesssee merged 3 commits into
mainfrom
fix/261-resize-idempotence
Aug 9, 2026
Merged

fix(tray): stop fractional-DPI tray-flyout resize oscillation via two-state cycle detection (#261)#272
Finesssee merged 3 commits into
mainfrom
fix/261-resize-idempotence

Conversation

@Finesssee

@Finesssee Finesssee commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #261

Summary

The anchored tray flyout can flap between two native heights at fractional DPI — the reporter (Win11 25H2, 125% scale, AppliedDPI 120, app zoom 100%) saw it alternate 379 ↔ 386 physical px at 2–4 Hz; expanding the on-pace graph (a big geometry change) stops it permanently. Both maintainer rigs (native seeded app, and a physics-faithful Chromium sandbox at deviceScaleFactor 1.25 running the same DOM + auto-fit logic) converge, so the exact OS-layer driver (WebView2 composition at fractional scale on 25H2) remains unconfirmed. What is confirmed is the feedback class: measure (logical px) → setSize (integer physical snap) → layout → measure again, disagreeing by a few px per pass.

This PR targets that class deterministically, replacing the old 2-logical-px deadband with an explicit, pure sizing-decision function (src/lib/traySizing.ts):

  • Normal rule — no blanket deadband. Any candidate whose quantized physical height differs from the committed frame commits, including a stable one-way +5 physical px (test-pinned). Suppression happens only for exact same-frame equality (committed or applied physical frame) — a genuine no-op.
  • Explicit two-state cycle detection. History of the last two committed physical targets; a candidate returning to the previous-previous frame (A→B→A) with pair span ≤ 8 physical px (reporter's loop amplitude: 7) is learned as an oscillation pair. A lone A→B small change is never classified as oscillation.
  • Retain the larger member. Once learned, the pair's larger logical height is kept (one convergence commit if currently at the smaller side, zero if already there), so the window fully contains the surface from either measured side — no clipping. In-pair flips are then suppressed.
  • Lock clearing. The lock resets on: a candidate outside the pair (real change), a width-class change, a min/max (layout-class) change, a zoom change, or a DPI/scaleFactor change (full state reset — physical frames are incomparable across DPI). Re-learning requires fresh A→B→A evidence, so unrelated nearby values are never suppressed indefinitely.
  • Applied-frame reconciliation. When the candidate's physical frame equals what Win32 actually applied (innerSize() readback after a fractional snap/clamp), the setSize is suppressed and the committed frame is replaced by the candidate's own — DOM constraint and comparisons now describe on-screen reality — without manufacturing a fake history transition.
  • surface.style.maxHeight follows the retained height. The DOM constraint is now assigned AFTER the decision from the retained committed height, so the surface and the window can never diverge (the old order clamped the surface to the freshly measured candidate even when the old window size was retained — a clip/instability source).

No timers, no debounces, no ResizeObserver gating: measurement always runs; only setSize/re-anchor commits are decided. Anchoring (applySize → reanchor on real commits only), manual/remembered sizing, zoom scaling (#266), min/max clamps, other tabs, and PopOut behavior are untouched — Rust/bridge untouched entirely.

Regression coverage (the exact 379↔386 pair)

  • src/lib/traySizing.test.ts — 13 pure tests modeling deviceScaleFactor 1.25: the reporter pair 303.2↔308.8 logical = 379↔386 physical detects once and converges to the larger member with zero further commits; boundary/clearing matrix (out-of-pair, width, min/max, zoom, DPI); lone-change and >8-span flips stay commits; applied-frame reconciliation (incl. prior untouched) and exact non-equality still committing.
  • src/hooks/useTrayPanelLayout.sizing.test.tsx — real-hook integration at devicePixelRatio 1.25 with integer-physical snapping readbacks: stable +5-physical growth commits with maxHeight following; alternation converges with zero extra setSize/re-anchor while surface.maxHeight stays at the retained larger height during suppressed passes; genuine growth/shrink clear and commit; a deliberately snapped readback (requested 674, applied 669) reconciles to the candidate with no churn and provably no accidental A↔B trigger.

Validation

  • Focused suites: 15/15 pass (traySizing + hook sizing)
  • Full frontend: 40 files / 246 tests pass
  • tsc --noEmit: clean
  • pnpm build (locale drift check + tsc + vite): pass
  • Rust/bridge: unchanged (no cargo gates needed)

Follow-ups (not in this PR)

  • Fresh CUA/native seeded proof on the reporter-shaped canvas is pending and tracked separately; the opt-in CODEXBAR_RESIZE_PROBE diagnostics branch stays local for the reporter build.
  • The reporter's PopOut discriminator question on [Bug]: Pop-up resizing glitch on Codex tab #261 is still open; this fix is surface-local to the flyout.

Do NOT merge yet — pending CUA proof + review sign-off.


View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

…cle 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.
…kers

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.
@Finesssee

Copy link
Copy Markdown
Collaborator Author

Validation checkpoint (native smoke on head 52bd428a)

  • Tested head: 52bd428aa3cd40ad8951b7653ae732211b6c3094 (merged with current main incl. feat(providers): add CodeBuddy CN credit usage provider #269).
  • Fresh debug build: success; codexbar-desktop-tauri.exe SHA-256 6580c5e3… (full hash in local records).
  • Condition caveat — this host is 100%/96 DPI, so this run is explicitly NON-reporter-condition (reporter: 125%/AppliedDPI 120). It validates that the cycle-detection build launches, auto-fits, re-opens and stays healthy at integer-scale; it does NOT exercise the 125% feedback class.
  • Native rect sampling: 20 s run — 211/211 samples constant 400×500, zero transitions; close + reopen 10 s — 106/106 constant, zero transitions.
  • Screenshots: three frames (settled, mid-run, re-opened) pixel-identical; no clipping, no scrollbar, no console errors observed.
  • Raw logs/screens/build hashes retained locally (no public artifacts attached).

Merge gate stands: an exact 125% / 120-DPI proof (reporter condition) is still required before this PR merges. Everything else — unit (13/13), hook integration, full frontend (246/246), tsc, build, CI — is green on this head.

@Finesssee

Copy link
Copy Markdown
Collaborator Author

Final validation — reporter-condition (125%) CUA proof on head 52bd428a

Acceptance environment this run: Windows Settings scale set to 125% via the visible Settings UI, fresh runtime DPI = 120 (DpiValue = 1), and app-reported scale factor = 1.25. The earlier AppliedDPI = 96 reading was a stale sign-in-registry cache — verified NOT the runtime gate (runtime DPI value + app scale confirm 125%).

  • Tested head: 52bd428aa3cd40ad8951b7653ae732211b6c3094; debug codexbar-desktop-tauri.exe SHA-256 6580c5e3… (full hash in local records).
  • Evidence: 211 window-rect samples over 19.9 s + 107 over 10 s (Codex detail) + 106 over 10 s (natural flyout open/close) = 424 samples total, one constant rect in each run, zero transitions. Three detail screenshots pixel-identical; no clipping, no scrollbar. Natural flyout opens bottom-anchored as before.
  • Scope of proof: these samples prove no oscillation and visual stability on the reporter's exact condition under this build; they do not by themselves introspect auto-fit internals (unit + hook tests cover that layer).
  • Cleanup: host restored and live-rechecked — Settings/runtime back to 100% / 96 DPI (DpiValue = 0), app scale = 1, desktop 1920×1080, no leftover app/vite/driver processes.
  • Raw logs, screenshots, and build hashes retained locally (no public artifacts attached).

All gates green on this head (13/13 unit, hook integration, 246/246 full frontend, tsc, build, CI). Landing per maintainer approval.

@Finesssee
Finesssee merged commit b455a5e into main Aug 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Pop-up resizing glitch on Codex tab

1 participant