feat(client): let a simulation results window be detached from its document window - #2045
Merged
Merged
Conversation
This was referenced Sep 1, 2026
…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
force-pushed
the
feat/detach-child-window
branch
from
September 1, 2026 21:13
25bee05 to
3a67131
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Boris, on a small screen:
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:JDialoggets no taskbar/dock button, and aDialogis not aFrame, so it has no iconified state at all. It cannot be minimized. There is no workaround.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
ec0ed8478afixed, where an un-owned frame can only be raised by a best-efforttoFront()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
ChildWindowalready keeps the caller'scontentPaneseparate from the window it currently sits in, and already rebuilds that window on everyshow()— 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:To support it, the bridge now reports what it previously left to inference —
/windowscarriesowner,canIconifyandiconified; a new/iconifyasks the real window manager and reports what actually happened, not that we calledsetExtendedState. 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,
canIconifyfalse, minimize refused). Runs clean twice in a row and normalizes state first.ChildWindowDetachTestadditionally 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
JMenuItemwas 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
AXRaiseand produced two byte-identical captures, becauseAXRaisereorders 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