diff --git a/.changeset/host-window-popout.md b/.changeset/host-window-popout.md deleted file mode 100644 index d7e0d484..00000000 --- a/.changeset/host-window-popout.md +++ /dev/null @@ -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. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 4e635d07..6ec92a2f 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -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 diff --git a/src/web/public/app.js b/src/web/public/app.js index 71f1cacc..075d3c14 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 = '

Session unavailable

' + '

This session has ended or is no longer available.

' - + ''; + + ''; document.body.appendChild(el); document.title = (window.codemanT?.('Session ended') || 'Session ended') + ' — ' + (window.CodemanI18n?.displayName || 'Codeman'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index c8ffd4bd..b1ec2194 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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); diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index ae906641..81e89211 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -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) }, diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 0cbf2a7d..7c3876f6 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -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'); }, diff --git a/test/host-window-detach.test.ts b/test/host-window-detach.test.ts index 59b67aa8..f840c203 100644 --- a/test/host-window-detach.test.ts +++ b/test/host-window-detach.test.ts @@ -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) { 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: () => {