Skip to content

feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper - #432

Open
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:feat/host-window-popout
Open

shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:feat/host-window-popout

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

On a foldable, the natural way to watch two sessions, or a session and a file, is two windows side by side. A browser can pop a session out with window.open, but an Android WebView wrapper has no pop-ups, and mobile.css hides the pop-out icon at those widths anyway. So a wrapper app had no way to reuse the solo window (/session/<id>) or the file viewer's detach route.

This adds a small, opt-in host contract. An embedding app that can open a page in a window of its own exposes:

  • window.CodemanHost.openWindow(absoluteUrl) → whether a window opened (required)
  • window.CodemanHost.closeWindow() / focusWindow() (optional; script-initiated window.close()/focus() do nothing in a window the page did not window.open)

When openWindow is present:

  • detachSession hands the solo URL to the host instead of window.open, marks the tab detached, and announces it on the window channel. Since there is no WindowProxy to poll, liveness uses the same roll-call path a reloaded dashboard already uses. The solo window's announcements and pagehide keep working unchanged. If the host refuses, the tab stays docked and a toast appears.
  • detachFilePreview and openWebviewExternal also go through the host.
  • html.host-windows keeps the tab pop-out icon at tablet widths (600–768px), and showTabDetachButton defaults on when unset. Phone tabs (≤599px) still hide it with !important, so the gear + close tap-zone arithmetic in mobile-tab-tap-zones stays valid. At that width the host offers the pop-out from its own chrome via app.detachSession(id).
  • A solo window's re-dock button and a dashboard close-request go through closeWindow() when the host offers it.

Browsers define none of this, so desktop and mobile browser behaviour is unchanged. openInHostWindow returns null when there is no host, and every call site falls through to the existing window.open code.

Validation:

  • New test/host-window-detach.test.ts (5 tests, vm-loaded app.js): the host path, a refusing host, a throwing host, the no-host fallthrough, and solo close/focus via the host. test/file-preview-detach.test.ts gained 2 host cases.
  • Every test file that references tab detach, mobile.css or the touched methods (16 files, 274 tests) passes, including mobile-tab-tap-zones and foldable-layout.
  • Playwright against a local build (1.29.0 + this commit) with an injected fake CodemanHost: at 700px the icon is visible and tapping it calls openWindow('<origin>/session/<id>'); at 440px the icon is hidden and app.detachSession takes the same path. Opening that URL in a second page gives solo mode, the dashboard tab stays detached across a roll-call, the solo re-dock button calls closeWindow(), and after the page closes the dashboard redocks. Without a host at 700px and 1280px there is no host-windows class and no icon (unchanged).
  • Prettier check passes and the production build succeeds.
  • A real device run of the companion Android wrapper (which opens the window with FLAG_ACTIVITY_LAUNCH_ADJACENT) has not been done yet. I am not claiming a physical-foldable test.

Includes a minor changeset. AI-assisted; implementation and validation reviewed locally.

🤖 Generated with Claude Code

…d from a native wrapper

An Android WebView wrapper has no browser pop-ups, so a foldable could not
show two sessions, or a session and a file, side by side. A wrapper that can
open a window of its own now exposes window.CodemanHost.openWindow(url);
detachSession, detachFilePreview and openWebviewExternal hand their URL to
it, mobile.css keeps the pop-out icon under html.host-windows, and a solo
window closes and raises itself through the host when it offers the calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Thanks for this. The two-windows-on-a-foldable problem is real, and you found the honest reason it could not work: a WebView wrapper has no window.open, and mobile.css was hiding the pop-out icon at those widths anyway, so there was no route to the feature at all rather than a broken one.

The code is careful. I read all three call sites and both CSS rules and it is genuinely inert without a host: openInHostWindow returns null so every path falls through to today's window.open, and html:not(.host-windows) always matches when the class is never stamped. Reusing the roll-call liveness path instead of inventing a second mechanism for the no-WindowProxy case is the right instinct. Checks are green here and on CI, and test/mobile-tab-tap-zones.test.ts still passes for the right reason: its mobileDeclarations() helper matched the old 768px rule and matches your new 599px rule, so the phone arithmetic really is untouched.

One thing genuinely needs to exist before this lands, and then a handful of one-liners.

1. The seam is documented nowhere. CLAUDE.md says third-party integration surfaces live in docs/extending-codeman.md, and that file opens by naming the four seams a third party can build against. This adds a fifth, and the only description of it is a JSDoc block in app.js. An Android developer cannot implement CodemanHost from the method names, because the parts that decide whether it works are exactly the parts that are not obvious:

  • openWindow has to return something other than false to count as success, so a void bridge method reads as "opened".
  • the URL is usually same-origin, but openWebviewExternal passes a saved web-tab URL on a foreign origin.
  • the page the host opens must share the dashboard's BroadcastChannel (same WebView profile) or the dashboard cannot track it. Open it in a Custom Tab or the system browser and the tab re-docks itself after roughly 5 to 11 seconds.
  • closeWindow and focusWindow are optional, and without focusWindow tapping a detached tab does nothing at all, with no feedback.

A short "Seam 5: native wrapper windows" section covering the three methods, the return contract and the BroadcastChannel requirement would do it, plus one line saying the contract is outside docs/versioning-policy.md (that policy covers the CLI, documented env vars and the HTTP/SSE API, not a JS global). You know what your wrapper actually needs from the contract better than I do, so I would rather you wrote it, but if you would rather I drafted it in the voice of that file, say so and I will.

2. _showSoloSessionGone() still hardcodes window.close() (app.js:1577). You converted the re-dock button and the close-request branch to _closeSoloWindow() and missed this one, so in a host window the button on the "this session has ended" screen does nothing.

3. openWebviewExternal falls back to window.open when the host refuses (webview-tabs.js:496). The other two call sites treat a refusal as final and toast. Here if (this.openInHostWindow?.(webview.url)) return; discards the null versus false distinction the helper exists to carry, and falls through to window.open, which in a WebView either does nothing at all or replaces the dashboard page with the dashboard URL. That second outcome is the exact thing this PR exists to avoid. Mirror the other two.

4. No liveness at all when BroadcastChannel is missing (app.js:1305-1313). The window.open path is self-healing because _watchDetachedWindow polls win.closed. The host path has no WindowProxy, so roll-call is its only liveness, and roll-call only exists when the channel does: _initWindowChannel returns early when BroadcastChannel is undefined, so _startDetachLiveness() is never reached. I loaded your app.js in a vm with no BroadcastChannel and a stub host: windowChannel is null, no interval is armed, detachSession('s1') still marks the tab detached with an empty detachedWindows, and _raiseDetached('s1') returns true, which makes selectSession return early. So the tab is marked detached, cannot be opened inline, and nothing ever un-marks it. redockSession() has no caller anywhere in the repo, so there is no UI escape either; only a dashboard reload clears it. Android System WebView has had BroadcastChannel since Chrome 54 so this is unlikely rather than likely, but it costs one condition to make it structural: take the host branch only when the channel exists.

5. The host-aware default is duplicated (settings-ui.js:468 and :2669). The same settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : ...) expression twice, once for the checkbox and once for the class. CLAUDE.md describes this exact shape for the plan-usage chip and requires a single resolver for precisely the two-call-sites-that-must-never-disagree reason. One tabDetachButtonEnabled(settings, defaults) helper called from both also fixes the tab-rail nit below.

Smaller:

  • The 600-768px band is the one band nothing guards. Your comment is right that the phone arithmetic is safe, but test/mobile-tab-tap-zones.test.ts declares tablets explicitly out of scope. In that block .tab-name has max-width: 80px with no min-width reserve, while the active tab's icons are the full-size desktop ones. A third icon on a tab named "w1" pushes the geometric centre further into the icon cluster, which is the mis-tap class CLAUDE.md documents at length for phones. The pressure already exists there from gear plus close, so you are widening a gap rather than opening one, but this is exactly the case you say has not been tried on a device. Either measure it at 700px on real hardware, or add the tablet twin of the phone reserve (.session-tab.active .tab-name { min-width: 44px } inside the 600-768px block) and extend that test to the tablet query.
  • The JSDoc at app.js:1343 says "same-origin", which is true for detachSession and detachFilePreview and false for openWebviewExternal. Nothing dangerous reaches native code, because webviewUrlSchema already restricts those to http/https with a hostname and no embedded credentials. The risk is what a wrapper author builds on that sentence: "same-origin" reads as permission to inject the CodemanHost bridge into every window the app opens, which would hand that bridge to a third-party dashboard.
  • tab-rail-resize.js:327 reads the raw stored showTabDetachButton, so under a host the "Open in a new window" row stays hidden in the rail's ... menu while the icon is showing on the tab. The helper from item 5 fixes it.
  • openWindow(...) !== false means a host returning undefined, which is the normal shape of a void Android @JavascriptInterface method, counts as success. That is the right default, but the changeset and JSDoc both say the host returns whether a window opened. Say "anything but false counts as opened" so a wrapper author knows a silent failure has to be reported explicitly.
  • index.html:150 calls app._closeSoloWindow(), an underscore-private, from markup. Every other inline handler in that file calls a public method. A public redockSoloWindow() reads better and is the name the host docs would use anyway.

One call that is mine rather than yours, and I want to be straight about it since you have done the work already: this adds a window.CodemanHost global whose only consumer is an app that does not exist in this repository and that you say has not been run on a device. Once a wrapper ships against it, those three method names are frozen in practice. I am inclined to take it, because the code is completely inert without a host so keeping it costs nothing operationally, and a foldable with two sessions side by side is a good reason to exist. But it does mean the docs section in item 1 is load-bearing rather than nice to have, since it is the only thing that will define what we are committing to.

Send those up and I will merge. If you want this in the next release, items 2 through 5 are the ones I need; the docs section can follow if it would hold you up, though I would rather land them together.

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.

2 participants