Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/host-window-popout.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 57 additions & 2 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="app._closeSoloWindow()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
</button>
Expand Down
13 changes: 11 additions & 2 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -2662,7 +2663,10 @@ Object.assign(CodemanApp.prototype, {
// default OFF, per-device). Mirrored as a class on <html>: 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);
Expand Down
4 changes: 3 additions & 1 deletion src/web/public/webview-tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions test/file-preview-detach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
127 changes: 127 additions & 0 deletions test/host-window-detach.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
const windowStub: Record<string, unknown> = {
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<string, any>;
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();
});
});