feat(web-tabs): open dashboard URLs as tabs beside agent sessions

Adds a "Web / URL" section to the Run dropdown. A saved URL renders as a tab in
the same strip as Claude/Codex/Gemini sessions, with the same Alt+1..9 numbering,
so Codeman is one mission control instead of Codeman plus a pile of browser tabs.

A webview is NOT a sixth SessionMode: no PTY, no tmux, no respawn, no idle
detection. It is a separate resource sharing only the tab strip and the main
content area, the same call that keeps Docker and remote-SSH as case overlays.

Dashboards are proxied through Codeman's own origin, because a direct iframe
fails three ways at once in the shipped deployment: prod serves HTTPS behind
tailscale serve, so http:// targets are hard-blocked as mixed content (with no
override at all on iOS Safari); Grafana/Portainer-class dashboards send
X-Frame-Options: DENY; and our own default-src 'self' CSP blocks cross-origin
frames. Proxying dissolves all three and leaves the production CSP byte-for-byte
unchanged, since /webview/... is already covered by 'self'. A useful side effect:
the fetch happens server-side, so a tailnet-only dashboard is reachable from a
phone that is not on the tailnet.

The proxy is not an API surface. It authenticates on a 192-bit capability in the
path (memory-only, rolling TTL, bound to the minting user, revoked on edit or
delete) and is correspondingly exempt from the cookie and Origin checks, because
a sandboxed iframe is opaque-origin: it sends no SameSite=lax cookie and its
writes arrive with Origin: null. The Host allowlist is never bypassed. A second
Referer-keyed form of the exemption exists for root-absolute assets and is fenced
to safe methods on non-/api, non-/ws, non-/q paths.

Iframes omit allow-same-origin unless a URL is explicitly marked trusted, since a
proxied page is served from Codeman's own origin and could otherwise read this
document and drive the agent-spawning API. Authorization and codeman_session are
stripped upstream in BOTH modes, so CODEMAN_PASSWORD cannot leak into a dashboard.

Two things only a real browser reveals, both presenting as the dashboard's own
"Failed to fetch" while the page itself renders fine:

- Runtime-built root-absolute URLs (fetch('/api/data')) escape <base href> and
  land on Codeman's root. Widening the Referer fallback into /api would trade
  security for it, so an injected shim patches fetch/XHR/WebSocket/EventSource
  inside the frame instead, removing the class rather than the guard.
- An opaque-origin document CORS-checks every request, including to the host it
  was served from. Script/css/img loads are not CORS-checked, which is why the
  page renders while its API calls die. The proxy now emits CORS headers and
  answers preflights itself. registerSecurityHeaders answered every OPTIONS with
  a bare 204 before routing, carrying no ACAO for Origin: null, so that
  short-circuit now exempts a valid capability.

Neither is reproducible with curl, which does not enforce CORS.

Also fixes a pre-existing bug found on the way: .toolbar has backdrop-filter,
making it a stacking context that trapped .run-mode-menu's z-index:1000, so
.welcome-overlay painted over the whole Run menu. With no session open, every
item in it (Claude Code included) was unclickable.

Verified end to end against a real tailnet dashboard: live data, WebSocket push,
no failed requests, and switching tabs does not reload the frame. 98 new tests
cover the pure rewrite helpers, the CORS helper, the shim's rewrite logic, route
CRUD, and every edge of the auth exemption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-27 17:06:36 +02:00
parent ea4c935d51
commit b34fcaf928
26 changed files with 3316 additions and 18 deletions
+44 -4
View File
@@ -294,6 +294,9 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
];
@@ -821,6 +824,7 @@ class CodemanApp {
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
this._initRunMode();
this.initWebviews?.();
this.setupEventListeners();
// Mobile: ensure button taps register even when keyboard is visible.
// On mobile, tapping a button while the soft keyboard is up causes the
@@ -1007,9 +1011,18 @@ class CodemanApp {
const digitMatch = code.match(/^Digit([1-9])$/);
if (digitMatch) {
const idx = parseInt(digitMatch[1], 10) - 1;
// Sessions occupy 1..N and web tabs continue from N+1, matching the
// numbers actually painted on the tabs.
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
} else {
const webIdx = idx - this.sessionOrder.length;
const webId = (this.webviewOrder || [])[webIdx];
if (webId) {
e.preventDefault();
this.openWebview(webId);
}
}
return;
}
@@ -3272,9 +3285,21 @@ class CodemanApp {
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Check if we can do incremental update (same session IDs)
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
// vacuously "unchanged" whenever session count is stable — most visibly with
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id));
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
if (canIncremental) {
// Incremental update - only modify changed properties
@@ -3282,7 +3307,12 @@ class CodemanApp {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
if (!tab) continue;
const isActive = id === this.activeSessionId;
// A web tab owns the active state while one is open. activeSessionId stays
// set (the terminal keeps streaming underneath, and switching back is
// instant): only the highlight moves. Without this the debounced render
// re-marks the session tab active moments after a web tab was selected,
// leaving two tabs lit at once.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const taskStats = session.taskStats || { running: 0, total: 0 };
@@ -3469,7 +3499,9 @@ class CodemanApp {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const isActive = id === this.activeSessionId;
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const mode = session.mode || 'claude';
@@ -3516,6 +3548,11 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
container.innerHTML = parts.join('');
// Set up drag-and-drop handlers for tab reordering
@@ -4050,6 +4087,9 @@ class CodemanApp {
return; // newer tab switch won
}
// A session tab takes the stage back from any active web tab.
this._hideWebviewLayer?.();
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
+3
View File
@@ -494,6 +494,9 @@ const SSE_EVENTS = {
// Session order (global tab order sync)
SESSION_ORDER_CHANGED: 'session:orderChanged',
// Web tabs (dashboard URLs)
WEBVIEW_CHANGED: 'webview:changed',
};
// ═══════════════════════════════════════════════════════════════
+64
View File
@@ -300,6 +300,11 @@
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
terminal while a web tab is active. Frames stay mounted while hidden so
switching tabs does not reload (and re-authenticate) a dashboard. -->
<div class="webview-layer" id="webviewLayer"></div>
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
<div class="welcome-content">
@@ -464,6 +469,14 @@
<span class="run-mode-dot shell"></span>Terminal / Shell
</button>
<div class="run-mode-sep"></div>
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
set runMode: the Run button always means "start an agent". -->
<div class="run-mode-header">Web / URL</div>
<div class="run-mode-webviews" id="runModeWebviews"></div>
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
</div>
@@ -639,6 +652,56 @@
</div>
</div>
<!-- Web Tab (dashboard URL) editor -->
<div class="modal" id="webviewModal">
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="webviewModalTitle">Add URL</h3>
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label for="webviewName">Name</label>
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label for="webviewUrl">URL</label>
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
autocapitalize="off" spellcheck="false">
<span class="form-hint">
Reached from the Codeman server, so a tailnet or localhost address works even when
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
Codeman, which is also what gets past dashboards that refuse to be embedded.
</span>
</div>
<div class="form-row">
<label for="webviewIcon">Icon</label>
<!-- Click to pick; the field stays editable so any emoji still works. -->
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
<span class="form-hint">
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
this lets its JavaScript read this page and call the API that starts agents. Uncheck
only for a dashboard you fully trust, or one whose own login needs cookies.
</span>
</div>
<div class="form-row">
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
</div>
</div>
<div class="form-actions webview-modal-actions">
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
</div>
</div>
</div>
<!-- Cron Jobs Modal -->
<div class="modal" id="cronModal">
<div class="modal-backdrop" onclick="app.closeCron()"></div>
@@ -2551,6 +2614,7 @@
<script defer src="ultracode-panel.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
+121
View File
@@ -3129,6 +3129,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
/* contain: style only — layout/paint containment clips the case-settings popover
that extends above the toolbar (popover uses position:absolute + bottom:100%) */
contain: style;
/* backdrop-filter above makes this a stacking context, which TRAPS the
z-index:1000 on .run-mode-menu inside it. Without an explicit z-index here the
toolbar resolves to auto (0) and .welcome-overlay (z-index:10, inside <main>)
paints over the popped-up Run menu: with no session open, every item in that
menu is unclickable. Must stay below .modal (1000). */
z-index: 20;
}
/* backdrop-filter creates a stacking context, trapping the popover's
@@ -11854,3 +11860,118 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: flex; gap: 0.5rem; justify-content: flex-end;
margin-top: 1rem; padding-top: 0.85rem; border-top: 1px solid var(--border);
}
/* ═══════════════════════════════════════════════════════════════
Web tabs (dashboard URLs embedded as tabs)
═══════════════════════════════════════════════════════════════ */
/* The iframe layer sits alongside .terminal-wrap inside <main> and only one of
the two is visible at a time. Frames stay in the DOM while hidden so switching
tabs does not reload a dashboard. */
.webview-layer {
display: none;
flex: 1;
min-height: 0;
position: relative;
background: var(--term-bg, #161b23);
}
.main.webview-active .webview-layer { display: flex; }
.main.webview-active .terminal-wrap { display: none; }
.webview-frame {
display: none;
position: absolute;
inset: 0;
}
.webview-frame.active { display: block; }
.webview-iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
/* Shown only when the frame never signalled load: a refused embed or an
unreachable host would otherwise be an unexplained blank rectangle. */
.webview-failure { display: none; }
.webview-frame--failed .webview-failure {
display: flex;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
background: var(--bg, #0f1319);
padding: 1.5rem;
text-align: center;
}
.webview-failure-inner { max-width: 380px; }
.webview-failure-inner h3 { margin: 0 0 0.5rem; font-size: 1rem; color: var(--text); }
.webview-failure-inner p { margin: 0 0 1rem; font-size: 0.85rem; color: var(--text-dim); line-height: 1.5; }
.webview-failure-actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
/* Tab styling: same shape as a session tab, distinguished by the globe icon and
a cool accent so a dashboard never reads as a running agent. */
.session-tab--web .tab-web-icon {
display: inline-flex;
align-items: center;
color: var(--text-dim);
flex-shrink: 0;
}
.session-tab--web.active .tab-web-icon { color: var(--accent, #3ec8ee); }
.session-tab--web.active { border-bottom-color: var(--accent, #3ec8ee); }
.run-mode-dot.web { background: #38bdf8; }
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
.run-mode-empty {
padding: 4px 10px 6px;
font-size: 0.75em;
color: var(--text-dim);
font-style: italic;
}
/* Icon picker: a compact grid above the free-text field, so the common case is a
click and the escape hatch (any emoji at all) stays available. */
.webview-icon-picker {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.webview-icon-choice {
width: 34px;
height: 34px;
font-size: 1.05rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-soft, rgba(255, 255, 255, 0.03));
cursor: pointer;
padding: 0;
}
.webview-icon-choice:hover { border-color: var(--accent, #3ec8ee); }
.webview-icon-choice.selected {
border-color: var(--accent, #3ec8ee);
box-shadow: 0 0 0 1px var(--accent, #3ec8ee) inset;
}
/* Saved-URL rows in the Run dropdown show their chosen icon in the dot's slot. */
.run-mode-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
margin-right: 6px;
font-size: 0.95em;
flex-shrink: 0;
}
.webview-probe-result.ok { color: var(--success, #10b981); }
.webview-probe-result.bad { color: var(--danger, #ef4444); }
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
.webview-modal-actions { justify-content: space-between; }
.webview-modal-actions .btn-danger { margin-right: auto; }
+445
View File
@@ -0,0 +1,445 @@
/**
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
* browser tabs.
*
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
* dashboard that reloads and re-authenticates on every tab switch is worse than
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
* least-recently-viewed eviction.
*
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
* trusted. Without that omission the page could read this document and call the
* API that spawns agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
*/
Object.assign(CodemanApp.prototype, {
// ── State ─────────────────────────────────────────────────────────────────
/** Load the saved list and restore which tabs were open. */
async initWebviews() {
this.webviews = this.webviews || new Map();
this.webviewOrder = this.webviewOrder || [];
this.activeWebviewId = this.activeWebviewId || null;
this._webviewMaxFrames = this._webviewMaxFrames || 6;
this._webviewFrameLru = this._webviewFrameLru || [];
await this.refreshWebviews();
// Restore the previously open web tabs (per device: which dashboards you keep
// open is a workspace-layout choice, not something to sync across machines).
let saved = [];
try {
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
} catch {
saved = [];
}
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
this.renderSessionTabs();
},
async refreshWebviews() {
const data = await this._apiJson('/api/webviews');
if (!data) return;
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
this.renderWebviewMenuItems();
},
/** SSE: the saved list changed (possibly on another device). */
async _onWebviewChanged(data) {
await this.refreshWebviews();
// A dashboard deleted elsewhere must not linger as a dead tab here.
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
this.renderSessionTabs();
},
_persistWebviewOrder() {
try {
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
} catch {
/* private mode / quota, order is a convenience, never fatal */
}
},
// ── Tab strip ─────────────────────────────────────────────────────────────
/**
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
* `startIndex` continues the Alt+N numbering after the session tabs.
*/
renderWebviewTabs(startIndex) {
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
const parts = [];
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
<span class="tab-info">
<span class="tab-name-row">
<span class="tab-name">${escapeHtml(webview.name)}</span>
</span>
</span>
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span>
</div>`);
idx++;
}
return parts.join('');
},
_webviewGlobeIcon() {
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
},
handleWebviewTabClick(event, id) {
event?.preventDefault?.();
return this.openWebview(id);
},
/** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() {
const container = this.$('sessionTabs');
if (!container) return;
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
}
if (this.activeWebviewId) {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
},
// ── Opening / closing ─────────────────────────────────────────────────────
/**
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
* memory-only and expire, so a tab reopened after a server restart must not reuse
* the dead URL from the previous run.
*/
async openWebview(id) {
const webview = this.webviews.get(id);
if (!webview) return;
if (!this.webviewOrder.includes(id)) {
this.webviewOrder.push(id);
this._persistWebviewOrder();
}
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
if (!data) {
this.showToast?.('Could not open URL', 'error');
return;
}
if (data.webview) this.webviews.set(id, data.webview);
const src = data.embedUrl || data.webview?.url || webview.url;
this._mountWebviewFrame(id, src, data.webview || webview);
this.activeWebviewId = id;
this.hideWelcome?.();
document.querySelector('.main')?.classList.add('webview-active');
this.renderSessionTabs();
this._updateActiveWebviewTab();
},
/** Create the frame if absent, then reveal it and hide its siblings. */
_mountWebviewFrame(id, src, webview) {
const layer = document.getElementById('webviewLayer');
if (!layer) return;
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'webview-frame';
wrap.dataset.webviewId = id;
const frame = document.createElement('iframe');
frame.className = 'webview-iframe';
frame.setAttribute('title', webview.name);
// No allow-same-origin unless explicitly trusted: a proxied page is served
// from THIS origin, so granting it would let the dashboard read this document
// and drive the Codeman API.
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
if (webview.trusted) sandbox.push('allow-same-origin');
frame.setAttribute('sandbox', sandbox.join(' '));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.src = src;
const failure = document.createElement('div');
failure.className = 'webview-failure';
failure.innerHTML = this._webviewFailureHtml(id);
wrap.appendChild(frame);
wrap.appendChild(failure);
layer.appendChild(wrap);
// A frame that never fires `load` is the normal symptom of a refused embed or
// an unreachable host. Show an actionable panel instead of a blank rectangle.
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
frame.addEventListener('load', () => {
clearTimeout(timer);
wrap.classList.remove('webview-frame--failed');
});
}
this._touchWebviewFrame(id);
for (const other of layer.querySelectorAll('.webview-frame')) {
other.classList.toggle('active', other.dataset.webviewId === id);
}
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
},
_webviewFailureHtml(id) {
const jsonId = escapeHtml(JSON.stringify(id));
return `<div class="webview-failure-inner">
<h3>This URL did not load</h3>
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
<div class="webview-failure-actions">
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
</div>
</div>`;
},
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
_touchWebviewFrame(id) {
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
this._webviewFrameLru.push(id);
const layer = document.getElementById('webviewLayer');
if (!layer) return;
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
const evict = this._webviewFrameLru.shift();
if (evict === this.activeWebviewId) continue;
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
}
},
reloadWebview(id) {
const target = id || this.activeWebviewId;
if (!target) return;
document
.getElementById('webviewLayer')
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
?.remove();
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
return this.openWebview(target);
},
openWebviewExternal(id) {
const webview = this.webviews.get(id || this.activeWebviewId);
if (webview) window.open(webview.url, '_blank', 'noopener');
},
closeWebviewTab(id) {
this._removeWebviewTab(id);
this.renderSessionTabs();
},
_removeWebviewTab(id) {
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
this._persistWebviewOrder();
document
.getElementById('webviewLayer')
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
?.remove();
if (this.activeWebviewId === id) {
this.activeWebviewId = null;
const next = this.webviewOrder[0];
if (next) {
this.openWebview(next);
} else {
this._hideWebviewLayer();
// Fall back to whatever session was last shown, or the welcome screen.
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
else this.showWelcome?.();
}
}
},
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
_hideWebviewLayer() {
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
this.activeWebviewId = null;
document.querySelector('.main')?.classList.remove('webview-active');
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
frame.classList.remove('active');
}
this._updateActiveWebviewTab();
},
// ── Run-menu entries ──────────────────────────────────────────────────────
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
const list = [...(this.webviews?.values() || [])];
if (list.length === 0) {
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
return;
}
container.innerHTML = list
.map(
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
JSON.stringify(w.id)
)})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
w.name
)}
</button>`
)
.join('');
},
openWebviewFromMenu(id) {
document.getElementById('runModeMenu')?.classList.remove('active');
return this.openWebview(id);
},
// ── Icon picker ───────────────────────────────────────────────────────────
/** Common dashboard/service glyphs. The text field stays open for anything else. */
_webviewIconChoices() {
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
},
_renderWebviewIconPicker(selected) {
const picker = document.getElementById('webviewIconPicker');
if (!picker) return;
picker.innerHTML = this._webviewIconChoices()
.map(
(icon) =>
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
)
.join('');
},
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
pickWebviewIcon(icon) {
const field = document.getElementById('webviewIcon');
if (!field) return;
field.value = field.value === icon ? '' : icon;
this._renderWebviewIconPicker(field.value);
},
// ── Editor modal ──────────────────────────────────────────────────────────
showWebviewModal(id) {
const modal = document.getElementById('webviewModal');
if (!modal) return;
const webview = id ? this.webviews.get(id) : null;
this._editingWebviewId = webview ? webview.id : null;
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
this._renderWebviewIconPicker(webview?.icon || '');
document.getElementById('webviewName').value = webview?.name || '';
document.getElementById('webviewUrl').value = webview?.url || '';
document.getElementById('webviewIcon').value = webview?.icon || '';
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
document.getElementById('webviewProbeResult').textContent = '';
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
document.getElementById('runModeMenu')?.classList.remove('active');
modal.classList.add('active');
document.getElementById('webviewName').focus();
},
closeWebviewModal() {
document.getElementById('webviewModal')?.classList.remove('active');
this._editingWebviewId = null;
},
/** Server-side probe: it runs from the network position the proxy will use. */
async testWebviewUrl() {
const url = document.getElementById('webviewUrl').value.trim();
const out = document.getElementById('webviewProbeResult');
if (!url) {
out.textContent = 'Enter a URL first.';
return;
}
out.textContent = 'Testing...';
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
if (!probe) {
out.textContent = 'Test failed (invalid URL?).';
return;
}
out.textContent = probe.reachable
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
: `Not reachable. ${probe.reason}`;
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
},
async saveWebview() {
const name = document.getElementById('webviewName').value.trim();
const url = document.getElementById('webviewUrl').value.trim();
const icon = document.getElementById('webviewIcon').value.trim();
const trusted = !document.getElementById('webviewSandboxed').checked;
if (!name || !url) {
this.showToast?.('Name and URL are required', 'error');
return;
}
// `icon: undefined` rather than null, the schema uses .optional(), which
// rejects an explicit null on the wire.
const body = { name, url, icon: icon || undefined, trusted };
const editing = this._editingWebviewId;
const data = editing
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
: await this._apiJson('/api/webviews', { method: 'POST', body });
if (!data) {
this.showToast?.('Could not save (check the URL)', 'error');
return;
}
await this.refreshWebviews();
this.closeWebviewModal();
if (editing) {
// The capability was revoked server-side by the edit, so a mounted frame is
// now pointing at a dead URL. Remount it.
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
} else {
this.openWebview(data.id);
}
},
async deleteWebview() {
const id = this._editingWebviewId;
if (!id) return;
const webview = this.webviews.get(id);
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
if (!res || !res.ok) {
this.showToast?.('Could not delete URL', 'error');
return;
}
this._removeWebviewTab(id);
this.webviews.delete(id);
this.closeWebviewModal();
this.renderWebviewMenuItems();
this.renderSessionTabs();
},
});