Skip to content

feat(client): let a simulation results window be detached from its document window - #2045

Merged
jcschaff merged 14 commits into
masterfrom
feat/detach-child-window
Sep 1, 2026
Merged

jcschaff merged 14 commits into
masterfrom
feat/detach-child-window

Conversation

@jcschaff

@jcschaff jcschaff commented Sep 1, 2026

Copy link
Copy Markdown
Member

Boris, on a small screen:

windows with sim results cannot be minimized to the computer display bar, and when they obscure other model windows, those widows loose focus completely.

This is the accepted trade-off, now biting

Both symptoms are direct consequences of ec0ed8478a, which made every MODELESS child window an owned dialog. That commit says so itself:

Trade-off accepted (per maintainer): owned windows have no independent taskbar button and travel with their owner under Spaces/Stage Manager/Snap.

  • "cannot be minimized" — an owned JDialog gets no taskbar/dock button, and a Dialog is not a Frame, so it has no iconified state at all. It cannot be minimized. There is no workaround.
  • "lose focus completely" — the OS keeps it above its owner by construction, so a results window overlapping the document window cannot be sent behind it. Clicking the document window raises it logically but it still draws underneath.

On a large screen you move the window aside and never notice. On a small one there is nowhere to move it to.

It is not revertible: un-owning it brings back the bug ec0ed8478a fixed, where an un-owned frame can only be raised by a best-effort toFront() that modern macOS cooperative activation and the Windows foreground lock refuse for a non-foreground app.

So make the trade-off the user's, per window

A "Detach Window" item in the child window's menu bar swaps the owned dialog for an un-owned LWChildFrame: the OS then gives it a real taskbar button, it minimizes, and it stacks freely — at the cost of no longer being pinned in front of its document window. "Reattach Window" puts it back. Attached remains the default, so nothing changes for anyone who does not go looking.

Offered for MODELESS windows only — a parent-modal window the user could send behind its parent would be an unreachable modal blocker.

Why this is a small change

A window's native owner is fixed at construction, so this rebuilds the window rather than flipping a flag. That is cheap only because ChildWindow already keeps the caller's contentPane separate from the window it currently sits in, and already rebuilds that window on every show() — title/size/resizable are cached fields for exactly that purpose. The viewer component and all its state carry across untouched; bounds carry across too, so the window does not jump.

Verification

Whether a window can be minimized is a property of the window manager, not of our Java code. It cannot be asserted headlessly.

tools/debug-bridge/scenarios/detach-window.sh — drives the real client and asserts on JSON. No screenshots, no image diffing, no judgement call:

== ATTACHED ==
  PASS  owner is a window (not null)      PASS  canIconify (= false)
  PASS  minimize request refused (= false)      <- the reported bug, asserted
== DETACHED ==
  PASS  owner is null                     PASS  canIconify (= true)
  PASS  minimize request honoured (= true)
  PASS  bounds unchanged by detaching
== REATTACHED ==
  PASS  owner is a window again           PASS  canIconify (= false)
  PASS  bounds unchanged over round trip
  passed: 11    failed: 0                 DETACH OK   (exit 0)

To support it, the bridge now reports what it previously left to inference — /windows carries owner, canIconify and iconified; a new /iconify asks the real window manager and reports what actually happened, not that we called setExtendedState. The interesting case is the one where the request is refused.

Confirmed it can go red. With the detach stubbed out, 3 assertions fail naming exactly what broke (owner still the document window, canIconify false, minimize refused). Runs clean twice in a row and normalizes state first.

ChildWindowDetachTest additionally covers the mechanics as a unit test — owned Dialog vs un-owned Frame, same content-pane instance, bounds preserved. It needs a display, so it is skipped in headless CI; the scenario script is what actually stands behind this change.

Caught by running it

The JMenuItem was being stretched by the menu bar's layout to 616px in a 650px window, making every bit of empty menu-bar space a live detach target. Pinned to preferred size; now 128px. A compile and a headless test would never have shown that.

Also worth recording: my first attempt at proving the z-order half used AXRaise and produced two byte-identical captures, because AXRaise reorders regardless of ownership. That is exactly why the verification is now built on OS-reported state rather than pixels.

Not verified

Windows. The script is plain POSIX shell plus curl, so it runs from Git Bash or WSL. Worth a run before this ships, since the Windows foreground lock is half the reason the owned-window change existed. Reporting from the OS rather than from pixels is what makes that run meaningful.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx

@jcschaff
jcschaff requested a review from danv61 September 1, 2026 19:44
@jcschaff jcschaff added client-e2e run the desktop client end-to-end window tests and removed client-e2e run the desktop client end-to-end window tests labels Sep 1, 2026
jcschaff and others added 14 commits September 1, 2026 17:13
…cument window

Boris, on a small screen: "windows with sim results cannot be minimized to the
computer display bar, and when they obscure other model windows, those windows
loose focus completely."

Both are direct consequences of ec0ed84, which made every MODELESS child window
an OWNED dialog. That was the right fix for its own problem - an un-owned frame can
only be raised with a best-effort toFront(), which modern macOS cooperative
activation and the Windows foreground lock refuse for a non-foreground app, so child
windows hid behind the document window. It is not revertible without bringing that
back. But the price lands squarely on the user:

  - an owned JDialog gets no taskbar or dock button, and a Dialog is not a Frame so it
    has no iconified state at all. It cannot be minimized. There is no workaround.
  - the OS keeps it above its owner by construction, so a results window overlapping
    the document window cannot be sent behind it.

On a large screen you move the window aside and never notice. On a small one there is
nowhere to move it to.

So make the trade-off the user's to make, per window, rather than ours for all windows.
A "Detach Window" item in the child window's menu bar swaps the owned dialog for an
un-owned LWChildFrame; "Reattach Window" puts it back. Attached remains the default,
so nothing changes for anyone who does not go looking.

A window's native owner is fixed at construction, so this rebuilds the window rather
than flipping a flag on it. That is cheap only because ChildWindow already keeps the
caller's contentPane separate from the window it currently sits in and rebuilds that
window on every show() - so the viewer component, and all its state, is carried across
untouched. Bounds are carried across too, so the window does not jump.

Detach is offered for MODELESS windows only. A parent-modal window the user could send
behind its parent would be a trap: an unreachable modal blocker.

Verified in the running client (not just compiled), on a live child window created
through the same ChildWindowManager.addChildWindow path the results viewer uses.
Minimizing via the macOS accessibility API, raising the window first in both cases so
the procedure is identical:

    attached  (ModelessChild)          AXMinimized := true  ->  reads back false
    detached  (DetachedModelessChild)  AXMinimized := true  ->  reads back true

i.e. exactly Boris's symptom, and gone once detached. The class swap, the menu label
flip, and bounds preservation across both directions were confirmed live too.

The menu item's maximum size is pinned to its preferred size: a JMenuItem in a
JMenuBar is stretched to fill the bar by the bar's layout, which measured 616px wide
in a 650px window and would have turned every bit of empty menu-bar space into a
detach button. Caught only by inspecting the running UI.

ChildWindowDetachTest covers the mechanics headlessly-skippable: owned Dialog vs
un-owned Frame, same content pane instance, bounds preserved. It needs a display, so
it is skipped in CI - the interactive check above is what stands behind this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
… per platform

Whether a window can be minimized, and whether the OS pins it above its owner, are
properties of the window manager rather than of our Java code. They cannot be
asserted headlessly, and up to now the only way to check them was a person looking
at a screen - or, worse, at screenshots, which is how the first attempt at this went
wrong: raising a window with the macOS accessibility API reorders it regardless of
ownership, so two captures taken either side of a detach came out byte-identical and
"proved" a result that was never there.

So have the bridge report the facts instead of inferring them from pixels.

/windows now carries, for every window:

    owner        title of the owning window, or null
    canIconify   whether it is a Frame at all - a Dialog has no iconified state,
                 which is precisely why an owned child window cannot be minimized
    iconified    current state, for Frames

and a new /iconify asks the real window manager to minimize or restore, then reports
what actually happened rather than that we called setExtendedState. That distinction
is the whole point: the interesting case is the one where the request is refused.

It polls for the result instead of sleeping a fixed interval. Minimize is animated
and asynchronous - the macOS dock genie, Windows' own transition - and a fixed wait
either flakes on a slow machine or wastes time on a fast one. A 400ms sleep was in
fact too short to see a restore complete on macOS, which showed up as a false failure.

scenarios/detach-window.sh drives the real UI and asserts on that JSON:

    attached    owner is the document window, canIconify false,
                and a minimize request is REFUSED          <- the reported bug
    detached    owner null, canIconify true,
                and a minimize request is HONOURED
    reattached  owned again, bounds unchanged across the whole round trip

Exit 0 / exit 1, no images, no judgement call. A human can watch it drive the client,
and PAUSE=1 slows it down enough to follow, but nothing about the verdict depends on
anyone watching.

Verified both ways on macOS: 11/11 pass against the real implementation, and with the
detach deliberately stubbed out it fails 3 assertions naming exactly what broke
(owner still the document window, canIconify false, minimize refused). Runs clean
twice in a row, and normalizes state first so a half-finished previous run does not
poison the next one.

Two robustness fixes the runs forced, both real rather than defensive: the script now
dismisses the source-build version-mismatch warning itself, and retries the menu that
opens the child window - the first activation after a modal dialog is dismissed is
swallowed while the dialog tears down, which cost two failed runs before it was
understood.

Windows is not verified yet; the script is plain POSIX shell plus curl, so it runs
from Git Bash or WSL, and reporting from the OS rather than from pixels is what makes
that run meaningful when someone does it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
…e text

Review feedback: a borderless menu item reading "Detach Window" sat oddly in a bar
whose only other control is an icon. Replace it with an icon and a tooltip, matching
the window-list control beside it.

The icon is drawn rather than shipped as a bitmap, so it follows the look and feel's
foreground colour, stays crisp on a HiDPI display, and adds no binary asset. It is the
familiar "open in new window" mark - a window outline left open at the top-right with
an arrow crossing that corner - so detaching reads without a legend, and the arrow
simply reverses for reattaching.

Two details that only came out of rendering it and looking:

  - the first drawing carried a title bar and ran the arrow through the outline. At
    16px that is mush. Dropping the title bar and moving the returning arrow's tip
    INTO the corner opening rather than inside the outline made both states legible.
  - the returning arrow was iterated offscreen at 12x against three alternatives
    rather than by relaunching the client each time.

Also, per review: the window-list (hamburger) control now appears only once the window
is DETACHED. Attached, the window is pinned above its owner, so jumping to another
window from here cannot actually reveal that window - it stays underneath. Detached,
the window is independent and can be minimized, and the list is then the way back to
it.

That change exposed a real bug in my own earlier commit. Clamping the item's maximum
size to its preferred size - added to stop a JMenuItem being stretched across the whole
menu bar - pinned the HEIGHT as well. With the hamburger removed there was nothing left
to hold the row open, the item collapsed to 9px against a 16px icon, and the glyph was
silently clipped: the box's bottom edge and the arrowhead simply were not drawn. Only
the width needed clamping.

No functional assertion noticed that, because nothing about it is functional, so the
scenario now checks the control is at least as large as the icon it has to draw. That
is the assertion that would have caught it.

scenarios/detach-window.sh updated for a control that no longer has text: it selects by
component name, and asserts the TOOLTIP flips between offering "Detach" and "Reattach" -
an icon whose tooltip did not track its state would be a silent lie - plus the presence
and absence of the window-list control. 17/17 pass on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
…it flaking

Two things stood between this script and a Windows run.

**Python.** It leans on python3 to read the bridge's JSON. Git Bash on Windows ships
curl, sed and sleep but no Python, and where Python is installed it is often "python"
rather than "python3". Resolve the interpreter once, and if there is none, say so and
exit 2 - rather than failing later with empty results that read as test failures.

**A flake, now understood rather than papered over.** Opening the child window
sometimes needed several attempts, because a menu activation that lands while the
version-mismatch dialog is still tearing down is swallowed. The retry loop hid that
until a run exhausted all five attempts and failed outright. Now the script waits for
the dialog to actually be GONE before going near a menu, which is deterministic rather
than a sleep, and the retry budget is only a backstop.

Three consecutive runs pass, 17/17 each. Still macOS - the Windows run is what this
change is for, and remains outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
Whether a window can be minimized, and whether the OS pins it above its owner, are
decided by the window manager rather than by our code. There is nothing to assert in a
headless JVM, so the detach/reattach behaviour has until now been checked by hand on
one machine - and the Windows half, which is where the foreground lock that motivated
the owned-window change lives, not at all.

Client E2E starts the real client on macOS and Windows runners and runs
scenarios/detach-window.sh against it, asserting on what those systems report back
rather than on screenshots.

workflow_dispatch only. It starts a GUI, it is slower and more fragile than a unit
test, and the behaviour changes rarely - so it is a tool to reach for when touching
window ownership or the logical-window framework, not a tax on every PR.

launch-client.sh needed one fix to work at all under Git Bash: the java on PATH there
is a WINDOWS binary, so it wants ';' between classpath entries and native C:\\... paths,
not the ':' and /c/... that shell uses. cygpath translates; every other platform keeps
the POSIX form unchanged, and the macOS launch is verified unaffected.

Both platforms run the same script through `shell: bash`. Logs are printed and uploaded
on failure either way - the client redirects its own stdout to ~/.vcell/logs, so the
launcher's output alone would only show what happened before that redirect.

Not yet run: this is the first attempt, and whether a fresh runner can reach
vcell-dev - and what the client does if it cannot - is exactly what the run will show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
workflow_dispatch is only offered for workflows already on the default branch, so the
branch that adds this workflow could never trigger its own first run. Add a
pull_request trigger whose job is gated on a 'client-e2e' label: opt-in, so an
unlabelled PR skips it and pays nothing, while this branch can actually be exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
…n the tooltip

Reported after dragging the window by hand: detaching snapped it back onto the
document window.

LWManager positions a window from componentShown - after setBounds, after the caller
has had its say. That is right for a window opening for the first time and wrong when
rebuilding one the user has already placed, and detaching rebuilds the window because
a native owner cannot be changed in place. So the remembered bounds were applied and
then immediately overridden.

It hid well: a window left where it opened already sits roughly where re-centring
would put it, so nothing moved visibly and the scenario's "bounds unchanged" assertion
passed throughout. It only shows once the window has been dragged somewhere else.
LWTraits.NOT_LW_MANAGED exists for exactly this and is now used when restoring bounds.

The scenario could not have caught this, so it now MOVES the window off-centre before
detaching, and a new bridge action makes that portable rather than a macOS-only trick.
Verified both ways: 18/18 with the fix, and with it reverted the two bounds assertions
fail with 80,90 -> 356,271 - the reported symptom, reproduced.

Also per review: shorter tooltips. "Detach: allow minimizing, but no longer kept in
front" / "Reattach: keep in front of the document window", instead of the sentence
each was before.

And the CI workflow's build, from its first run on real runners:

  - install, not compile. launch-client.sh resolves the classpath with
    dependency:build-classpath -pl vcell-client (no -am), so the sibling
    0.0.1-SNAPSHOT artifacts must already be in the local repository. Now runs
    install with dependency:copy-dependencies, matching the documented build.
  - -Dproject.build.sourceEncoding=UTF-8. The root pom sets no encoding, so javac
    falls back to the platform default and Windows read UTF-8 sources as
    windows-1252: "unmappable character (0x9D)". Worth fixing in the pom - every
    Windows build of this repo has the same problem - but set here so this workflow
    does not wait on that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
Second E2E attempt got past the build and died identically on both platforms:

  IllegalStateException: vcell.installDir value ...\Applications\VCell_Alpha
  is not a directory   (PropertyLoader.validateSystemProperties)

The client validates that vcell.installDir exists and exits if it does not. On a
developer machine that is the local install4j installation; a runner has none. Create
an empty one and point VCELL_INSTALL_DIR at it, which launch-client.sh already honours.

$HOME rather than $RUNNER_TEMP: under Git Bash the latter is a Windows path with
backslashes, which that shell mangles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
macOS now passes end to end. Windows failed in the BUILD step, not the test, with a
wall of ChecksumFailureException (REMOTE_EXTERNAL) - resumed or partial downloads
landing corrupt in a cold local repository, which is a known Windows CI failure mode
and nothing to do with the client.

Turn off download resumption, and if it happens anyway retry once from a cleared local
repository: a corrupt artifact is sticky, so without that every later run would fail
the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
The Windows job failed on 'upload logs' AFTER the scenario had passed 18/18. The upload
action does not run through the job's shell, so it never expands '~' and takes a Git
Bash '/tmp/...' literally - not a valid Windows path, which is an error rather than a
miss. Copy the launcher output and the client log into the workspace first and upload
that, which behaves the same on both platforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
Linux is the cheap lane: ubuntu runners are far faster and cheaper than macOS or
Windows, so this is the one that can run often, with the other two kept for the checks
that only they can answer.

Xvfb alone would not do. Minimizing is a WINDOW MANAGER function, and with no WM
running the iconify request goes nowhere - the test would fail for a reason with
nothing to do with VCell, which is worse than not running it. openbox is small, starts
instantly, and implements the parts this exercises: iconification and stacking.

The Java-level assertions (owner, canIconify, bounds) hold on any platform; what each
lane actually buys is the one assertion that depends on a real window manager honouring
a minimize request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
The Linux lane failed in 'give Linux a desktop to draw on'. Xvfb itself started - the
runner's cleanup even reported terminating it as an orphan - but the readiness probe
used xdpyinfo, which lives in x11-utils and was not installed, so the check reported
the display as never coming up.

Install x11-utils, and say in the failure message that a missing xdpyinfo is one of the
two things that message can mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
Per review. The label-gated pull_request trigger existed to bootstrap: GitHub only offers
workflow_dispatch for workflows already on the default branch, so the branch ADDING this
workflow could not otherwise have triggered its own first run. That job is done - it has
now run green on ubuntu, windows and macOS - and a GUI run on three runners is far too
heavy to sit in front of a merge.

Nightly at 07:40 UTC, after the existing 07:00 nightlies so the runner load is spread,
plus workflow_dispatch for the case that actually matters: someone about to touch window
ownership or the logical-window framework.

Note this cannot be dispatched from a branch until it is on master; it is verified green
on all three platforms as of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
#2047 set project.build.sourceEncoding=UTF-8 in the root pom, so passing it on the
command line here is redundant. Removing it as promised in that PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
@jcschaff
jcschaff force-pushed the feat/detach-child-window branch from 25bee05 to 3a67131 Compare September 1, 2026 21:13
@jcschaff
jcschaff merged commit fb87af5 into master Sep 1, 2026
9 checks passed
@jcschaff
jcschaff deleted the feat/detach-child-window branch September 1, 2026 22:52
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.

1 participant