mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(mobile): #432 landing fixes
Applies the review's landing list for the native-wrapper window bridge, with the verifier corrections. detachSession now refuses before asking the host when there is no window channel (no BroadcastChannel). Without the channel there is no roll-call liveness, so a hosted tab could never re-dock and would stay detached, and excluded from tiles and split, until the session ended. The guard sits before the host call so a channel-less host never gets a native window and a window.open as well. A single resolver, tabDetachButtonEnabled(), now lives in app.js next to hasHostWindows() and decides the host-aware pop-out default for the tab icon, App Settings and the tab action menu (which also serves the tile grid's menu). Before this the menu read the raw setting and hid "Open in a new window" under a host. The menu and both settings-ui.js sites call it optionally with a fallback, because test/session-sidebar-ux.browser.test.ts loads tab-rail-resize.js onto a bare CodemanApp without app.js, and a bare call would throw before the menu is appended. The "Close window" button on the solo session-gone overlay goes through _closeSoloWindow(), as the re-dock button already did, so it works in a host window. openWebviewExternal no longer falls through to window.open when the host refuses (in a WebView that can replace the dashboard page); it toasts instead, like the session and file-preview paths. The hasHostWindows and openInHostWindow JSDoc now say what the code does: anything but false counts as opened, and a saved web tab passes its own origin. docs/versioning-policy.md lists the window.CodemanHost bridge under experimental surfaces, so it does not read as a stable contract until the wrapper docs section lands. test/host-window-detach.test.ts gives the harness a live window channel (Object.create leaves it undefined, which the new guard would refuse) and pins the no-channel refusal. The per-PR changeset is removed; the release writes one consolidated changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper
|
||||
|
||||
A WebView app has no browser pop-ups, so "Open in a new window" had nothing to open on a
|
||||
phone, and mobile.css hid it there. An embedding app that can put a page in a window of its
|
||||
own (an Android app on a foldable or in split screen) now says so with
|
||||
`window.CodemanHost.openWindow(absoluteUrl)`, returning whether a window opened. When it is
|
||||
present, the tab pop-out, the file viewer's detach button and a web tab's "open externally"
|
||||
go through it, and the tab's pop-out icon defaults on and shows at tablet widths (phone tabs
|
||||
keep their gear + close tap zones, so the host offers the pop-out from its own chrome through
|
||||
`app.detachSession`). The dashboard
|
||||
tracks such a window over the existing window channel, the path a reloaded dashboard already
|
||||
uses, and a solo window closes and raises itself through the optional
|
||||
`CodemanHost.closeWindow()` / `CodemanHost.focusWindow()`. Browsers define none of these, so
|
||||
nothing changes there.
|
||||
@@ -57,8 +57,10 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
||||
programmatically is not supported (there is no stable library entry point).
|
||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||
Gesture Control (beta), Agent Teams
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
||||
in the UI or docs. These may change or be removed at any time.
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
|
||||
anything labeled experimental in the UI or docs. These may change or be
|
||||
removed at any time.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
|
||||
+21
-6
@@ -1673,8 +1673,11 @@ class CodemanApp {
|
||||
// open the solo URL in a window of its own, beside this one on a foldable or
|
||||
// a split screen. There is no WindowProxy to poll, so the tab is tracked the
|
||||
// way a dashboard reload tracks it: the solo window's channel announcements
|
||||
// plus the roll-call liveness check.
|
||||
const hosted = this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
|
||||
// plus the roll-call liveness check. Without a channel there is no roll-call
|
||||
// either, so a hosted tab could never re-dock: refuse before asking the host.
|
||||
const hosted = this.hasHostWindows() && !this.windowChannel
|
||||
? false
|
||||
: this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
|
||||
if (hosted !== null) {
|
||||
if (!hosted) {
|
||||
this.showToast?.('Could not open a new window for this session', 'error');
|
||||
@@ -1700,8 +1703,8 @@ class CodemanApp {
|
||||
|
||||
/**
|
||||
* The embedding app's window opener, when there is one. A native wrapper
|
||||
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (returning whether a
|
||||
* window opened) to say it can put a page in a window of its own; browsers
|
||||
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
|
||||
* counts as opened) to say it can put a page in a window of its own; browsers
|
||||
* never define it.
|
||||
* @returns {boolean} whether a host window opener is present
|
||||
*/
|
||||
@@ -1712,7 +1715,19 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a same-origin page in a host window.
|
||||
* The tab pop-out setting, defaulting ON under a host that opens windows.
|
||||
* The one resolver for the tab icon, App Settings and the tab action menu.
|
||||
* @param {object} settings stored per-device App Settings
|
||||
* @param {object} [defaults] the device's default settings
|
||||
* @returns {boolean} whether the pop-out control shows
|
||||
*/
|
||||
tabDetachButtonEnabled(settings, defaults = {}) {
|
||||
return settings?.showTabDetachButton ?? (this.hasHostWindows() || (defaults?.showTabDetachButton ?? false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an http(s) URL in a host window, usually Codeman's own origin (a saved
|
||||
* web tab passes its own).
|
||||
* @param {string} url absolute or base-relative URL
|
||||
* @returns {boolean|null} null when there is no host (use window.open),
|
||||
* otherwise whether the host opened a window
|
||||
@@ -1949,7 +1964,7 @@ class CodemanApp {
|
||||
el.className = 'solo-gone-overlay';
|
||||
el.innerHTML = '<h2>Session unavailable</h2>'
|
||||
+ '<p>This session has ended or is no longer available.</p>'
|
||||
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
||||
+ '<button class="btn-primary" onclick="app._closeSoloWindow()">Close window</button>';
|
||||
document.body.appendChild(el);
|
||||
document.title = (window.codemanT?.('Session ended') || 'Session ended')
|
||||
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||
|
||||
@@ -507,7 +507,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
||||
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||
settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false));
|
||||
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
@@ -3725,7 +3726,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Under a host that opens windows (see hasHostWindows) popping out is the
|
||||
// way to get two panes side by side, so the button defaults on there.
|
||||
const showTabDetach =
|
||||
settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false));
|
||||
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
|
||||
@@ -329,7 +329,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||
|
||||
@@ -515,7 +515,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (!webview) return;
|
||||
if (this.openInHostWindow?.(webview.url)) return;
|
||||
// A host that refuses must not fall through to window.open, which in a
|
||||
// WebView can replace the dashboard page.
|
||||
const hosted = this.openInHostWindow?.(webview.url) ?? null;
|
||||
if (hosted !== null) {
|
||||
if (!hosted) this.showToast('Could not open a new window for this dashboard', 'error');
|
||||
return;
|
||||
}
|
||||
window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* already uses),
|
||||
* 2. a host that refuses leaves the tab docked and toasts,
|
||||
* 3. without a host nothing changes (`openInHostWindow` returns null),
|
||||
* 4. a solo window closes and raises itself through the host when it can.
|
||||
* 4. a solo window closes and raises itself through the host when it can,
|
||||
* 5. without a window channel (no BroadcastChannel) the host is never asked,
|
||||
* since a hosted tab could not re-dock without the roll-call.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts).
|
||||
*/
|
||||
@@ -58,6 +60,8 @@ function load(host?: Record<string, unknown>) {
|
||||
app.sessions = new Map([['s1', {}]]);
|
||||
app.detachedSessions = new Set();
|
||||
app.detachedWindows = new Map();
|
||||
// A live window channel, as _initWindowChannel would open in a browser.
|
||||
app.windowChannel = {};
|
||||
app.$ = () => null;
|
||||
app.showToast = vi.fn();
|
||||
app._postWindowMessage = vi.fn();
|
||||
@@ -90,6 +94,19 @@ describe('detach through a host window opener', () => {
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||
});
|
||||
|
||||
it('refuses without a window channel, so the tab is never stuck detached', () => {
|
||||
const openWindow = vi.fn().mockReturnValue(true);
|
||||
const { app, windowStub } = load({ openWindow });
|
||||
app.windowChannel = null;
|
||||
|
||||
app.detachSession('s1');
|
||||
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
expect(windowStub.open).not.toHaveBeenCalled();
|
||||
expect(app.detachedSessions.size).toBe(0);
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||
});
|
||||
|
||||
it('treats a throwing host as a failed open', () => {
|
||||
const { app } = load({
|
||||
openWindow: () => {
|
||||
|
||||
Reference in New Issue
Block a user