From 0720526b64c79820562749ce3c6c744164dc1024 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Tue, 15 Sep 2026 20:13:05 +0800 Subject: [PATCH] feat(mobile): pop a session or a file preview out beside the dashboard 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) --- .changeset/host-window-popout.md | 18 +++++ src/web/public/app.js | 59 +++++++++++++- src/web/public/index.html | 2 +- src/web/public/mobile.css | 13 +++- src/web/public/panels-ui.js | 6 ++ src/web/public/settings-ui.js | 8 +- src/web/public/webview-tabs.js | 4 +- test/file-preview-detach.test.ts | 24 ++++++ test/host-window-detach.test.ts | 127 +++++++++++++++++++++++++++++++ 9 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 .changeset/host-window-popout.md create mode 100644 test/host-window-detach.test.ts diff --git a/.changeset/host-window-popout.md b/.changeset/host-window-popout.md new file mode 100644 index 000000000..d7e0d4844 --- /dev/null +++ b/.changeset/host-window-popout.md @@ -0,0 +1,18 @@ +--- +"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/src/web/public/app.js b/src/web/public/app.js index 4c6888997..86c36fe7c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -910,6 +910,8 @@ class CodemanApp { // strip never flashes before handleInit selects the target session. this._initWindowChannel(); if (this.isSoloWindow) document.body.classList.add('solo-mode'); + // mobile.css keeps the pop-out icon off phones unless a host can open windows. + document.documentElement.classList.toggle('host-windows', this.hasHostWindows()); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); @@ -1295,6 +1297,21 @@ class CodemanApp { // false only when we owned a now-closed window (re-dock + fall through to // genuinely re-open below). if (this.detachedSessions.has(id) && this._raiseDetached(id)) return; + // A native wrapper (an Android WebView app) has no browser pop-ups, but can + // 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))); + if (hosted !== null) { + if (!hosted) { + this.showToast?.('Could not open a new window for this session', 'error'); + return; + } + this._markDetached(id, true); + this._postWindowMessage({ type: 'detached', id }); + return; + } const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'; let win = null; try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {} @@ -1309,6 +1326,32 @@ class CodemanApp { try { win.focus(); } catch {} } + /** + * 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 + * never define it. + * @returns {boolean} whether a host window opener is present + */ + hasHostWindows() { + try { + return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function'; + } catch { return false; } + } + + /** + * Open a same-origin page in a host window. + * @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 + */ + openInHostWindow(url) { + if (!this.hasHostWindows()) return null; + try { + return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false; + } catch { return false; } + } + /** Raise the popup for an already-detached session. Returns true if the raise * was handled (caller should stop); false if we owned a now-closed window and * re-docked it (caller should fall through to inline / re-open). Unifies the @@ -1435,8 +1478,12 @@ class CodemanApp { // Roll-call has no id (broadcast to all) — answer before the id filter. if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; } if (msg.id !== this.soloSessionId) return; - if (msg.type === 'close-request') { try { window.close(); } catch {} } - else if (msg.type === 'focus-request') { try { window.focus(); } catch {} } + // A host window ignores window.close()/focus() from script it did not + // open by window.open, so ask the host when it offers the call. + if (msg.type === 'close-request') { this._closeSoloWindow(); } + else if (msg.type === 'focus-request') { + try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {} + } return; } // Dashboard side. @@ -1488,6 +1535,14 @@ class CodemanApp { }, 1200); } + /** Solo window: close itself (the re-dock button and a dashboard close-request). */ + _closeSoloWindow() { + try { + if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow(); + else window.close(); + } catch {} + } + /** Solo window: select the target session and apply minimal single-session * chrome. Called from handleInit once the session list has loaded. */ _applySoloMode() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 39e956f1b..500a05a34 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -147,7 +147,7 @@ Admin Panel - + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9be29153c..9f57dbc6c 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel { /* No "open in new window" (detach) on phones/tablets — popped-out browser windows aren't usable there. !important beats the hover/detached reveal - rules in styles.css */ - .session-tab .tab-detach { + rules in styles.css. A native wrapper that opens windows of its own (side + by side on a foldable) keeps it at tablet widths: app.js sets + html.host-windows. Phone widths hide it again in the 599px block. */ + html:not(.host-windows) .session-tab .tab-detach { display: none !important; } } @@ -730,6 +732,13 @@ html.mobile-init .file-browser-panel { display: none; } + /* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones), + so the pop-out icon stays off phone tabs even under a window-opening host, + which offers the pop-out from its own chrome (app.detachSession). */ + .session-tab .tab-detach { + display: none !important; + } + /* Gear icon on active tab - tiny, subtle */ .session-tab.active .tab-gear { display: inline-flex; diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 80e6ce838..18254d44b 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4203,6 +4203,12 @@ Object.assign(CodemanApp.prototype, { */ detachFilePreview() { if (!this.filePreviewDetachUrl) return; + const hosted = this.openInHostWindow?.(this.filePreviewDetachUrl) ?? null; + if (hosted !== null) { + if (hosted) this.closeFilePreview(); + else this.showToast('Could not open a new window for this preview', 'error'); + return; + } const win = window.open(this.filePreviewDetachUrl, '_blank'); if (!win) { this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 92631b0fe..e5a7447a4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -464,7 +464,8 @@ Object.assign(CodemanApp.prototype, { settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; document.getElementById('appSettingsTabRailSort').value = settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; - document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; + document.getElementById('appSettingsShowTabDetachButton').checked = + settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false)); document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( @@ -2662,7 +2663,10 @@ Object.assign(CodemanApp.prototype, { // default OFF, per-device). Mirrored as a class on : styles.css hides // .tab-detach without it (a tab that is already detached keeps its icon as // the re-focus affordance for the popped-out window). - const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; + // 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)); 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/webview-tabs.js b/src/web/public/webview-tabs.js index 98d40188e..f0369f3f6 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -492,7 +492,9 @@ Object.assign(CodemanApp.prototype, { openWebviewExternal(id) { const webview = this.webviews.get(id || this.activeWebviewId); - if (webview) window.open(webview.url, '_blank', 'noopener'); + if (!webview) return; + if (this.openInHostWindow?.(webview.url)) return; + window.open(webview.url, '_blank', 'noopener'); }, closeWebviewTab(id) { diff --git a/test/file-preview-detach.test.ts b/test/file-preview-detach.test.ts index 6773386cc..9f80537c8 100644 --- a/test/file-preview-detach.test.ts +++ b/test/file-preview-detach.test.ts @@ -128,6 +128,30 @@ describe('file viewer detach button', () => { expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error'); }); + it('hands the URL to a host window opener instead of window.open', () => { + const { app, overlay, windowStub } = loadApp(); + app.openInHostWindow = vi.fn().mockReturnValue(true); + app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf'; + + app.detachFilePreview(); + + expect(app.openInHostWindow).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf'); + expect(windowStub.open).not.toHaveBeenCalled(); + expect(overlay.classList.contains('visible')).toBe(false); + }); + + it('keeps the overlay and toasts when the host could not open a window', () => { + const { app, overlay, windowStub } = loadApp(); + app.openInHostWindow = vi.fn().mockReturnValue(false); + app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf'; + + app.detachFilePreview(); + + expect(windowStub.open).not.toHaveBeenCalled(); + expect(overlay.classList.contains('visible')).toBe(true); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); + }); + it('does nothing when no preview is armed', () => { const { app, windowStub } = loadApp(); app.filePreviewDetachUrl = ''; diff --git a/test/host-window-detach.test.ts b/test/host-window-detach.test.ts new file mode 100644 index 000000000..59b67aa8a --- /dev/null +++ b/test/host-window-detach.test.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview Pop-out through a native host's window opener. + * + * An Android WebView wrapper has no browser pop-ups: `window.open` there either + * does nothing or replaces the page. On a foldable the app can still put a page + * in a window of its own beside the dashboard, and says so by exposing + * `window.CodemanHost.openWindow(absoluteUrl)`. When it does: + * 1. `detachSession` hands the solo URL to the host instead of `window.open`, + * marks the tab detached and announces it on the window channel (there is + * no WindowProxy, so liveness is the roll-call path a reloaded dashboard + * 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. + * + * Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts). + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +function load(host?: Record) { + const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: vi.fn(), + close: vi.fn(), + focus: vi.fn(), + }; + if (host) windowStub.CodemanHost = host; + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + URL, + location: { href: 'http://10.0.0.2:8095/' }, + document: { addEventListener: vi.fn() }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: {}, + }); + const constants = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8'); + const source = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = Object.create(CodemanApp.prototype) as Record; + app.isSoloWindow = false; + app.sessions = new Map([['s1', {}]]); + app.detachedSessions = new Set(); + app.detachedWindows = new Map(); + app.$ = () => null; + app.showToast = vi.fn(); + app._postWindowMessage = vi.fn(); + app._watchDetachedWindow = vi.fn(); + return { app, windowStub }; +} + +describe('detach through a host window opener', () => { + it('opens the solo URL in a host window and tracks the tab over the channel', () => { + const openWindow = vi.fn().mockReturnValue(true); + const { app, windowStub } = load({ openWindow }); + + app.detachSession('s1'); + + expect(openWindow).toHaveBeenCalledWith('http://10.0.0.2:8095/session/s1'); + expect(windowStub.open).not.toHaveBeenCalled(); + expect(app.detachedSessions.has('s1')).toBe(true); + expect(app.detachedWindows.size).toBe(0); + expect(app._watchDetachedWindow).not.toHaveBeenCalled(); + expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'detached', id: 's1' }); + }); + + it('leaves the tab docked and toasts when the host opens nothing', () => { + const { app, windowStub } = load({ openWindow: vi.fn().mockReturnValue(false) }); + + app.detachSession('s1'); + + expect(windowStub.open).not.toHaveBeenCalled(); + expect(app.detachedSessions.has('s1')).toBe(false); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); + }); + + it('treats a throwing host as a failed open', () => { + const { app } = load({ + openWindow: () => { + throw new Error('bridge gone'); + }, + }); + + expect(app.openInHostWindow('/session/s1')).toBe(false); + }); + + it('keeps window.open when there is no host', () => { + const { app, windowStub } = load(); + + expect(app.hasHostWindows()).toBe(false); + expect(app.openInHostWindow('/session/s1')).toBeNull(); + app.detachSession('s1'); + expect(windowStub.open).toHaveBeenCalledWith('/session/s1', 'codeman-session-s1', expect.any(String)); + }); + + it('closes and raises a solo window through the host', () => { + const closeWindow = vi.fn(); + const focusWindow = vi.fn(); + const { app, windowStub } = load({ openWindow: vi.fn(), closeWindow, focusWindow }); + app.isSoloWindow = true; + app.soloSessionId = 's1'; + + app._onWindowMessage({ type: 'focus-request', id: 's1' }); + app._onWindowMessage({ type: 'close-request', id: 's1' }); + + expect(focusWindow).toHaveBeenCalledTimes(1); + expect(closeWindow).toHaveBeenCalledTimes(1); + expect(windowStub.close).not.toHaveBeenCalled(); + expect(windowStub.focus).not.toHaveBeenCalled(); + }); +});