merge master into mobile-path-picker-preview

Only CLAUDE.md conflicted: master restructured it into the short-rule +
docs/architecture-invariants.md pointer layout while this PR was open.
Route counts reconciled against master's numbering (files 14 -> 16 for
the two new filesystem endpoints, total ~197 -> ~199) and the path
picker's detail moved into architecture-invariants under its own
section.
This commit is contained in:
Codeman maintainer
2026-07-28 01:13:32 +02:00
47 changed files with 4517 additions and 356 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',
};
// ═══════════════════════════════════════════════════════════════
+92 -10
View File
@@ -45,20 +45,20 @@
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Apply the saved per-device language before first paint. The full translation
layer loads below; setting lang/dir here prevents an English accessibility
tree from flashing while the deferred scripts start. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
.skeleton-header{height:40px;background:var(--glass-bg,rgba(31,38,48,0.85));border-bottom:1px solid var(--glass-border,rgba(255,255,255,0.08));display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
@@ -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">
@@ -460,6 +465,18 @@
<span class="run-mode-dot gemini"></span>Gemini
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<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>
@@ -475,6 +492,12 @@
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
Run Shell
</button>
<!-- Phone-only: replaces the Shell button on ≤430px (Shell moves into the Run
dropdown there). Sends a bare Enter to the active session, the complement
to the accessory bar's Esc. Hidden everywhere else — see styles.css. -->
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
Enter
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
@@ -629,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>
@@ -1190,9 +1263,17 @@
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
<optgroup label="Light">
<option value="paper-gray">Paper Gray</option>
<option value="solarized-light">Solarized Light</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
<option value="rose-pine-dawn">Rosé Pine Dawn</option>
</optgroup>
<optgroup label="Dark">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</optgroup>
</select>
</div>
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
@@ -2544,6 +2625,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>
+98 -26
View File
@@ -875,19 +875,44 @@ html.mobile-init .file-browser-panel {
margin-right: 0;
}
/* Secondary action - Run Shell - right side */
/* Shell is NOT a toolbar button on phones — it moved into the Run dropdown
(Terminal / Shell), freeing this slot for Enter. Starting a shell is a rare,
deliberate act; sending Enter is a constant one, so the scarce phone real
estate goes to Enter. */
.btn-toolbar.btn-shell {
flex: 0 0 auto;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #9ca3af;
order: 4; /* Right position */
display: none !important;
}
.btn-toolbar.btn-shell:hover,
.btn-toolbar.btn-shell:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
/* Secondary action - Enter - right side. Takes the slot (and the order) the
Shell button used to hold, so the toolbar rhythm is unchanged. */
.btn-toolbar.btn-enter {
display: flex !important;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.01em;
/* !important is REQUIRED here, not defensive habit: styles.css nests its skin
overrides inside `html:not([data-skin="og"]) { … }`, so a plain `.btn-toolbar`
in that block resolves to (0,2,1) and outranks this (0,2,0) rule. Without
!important the button silently renders in generic toolbar grey. */
background: rgba(30, 58, 95, 0.85) !important;
border: 1px solid rgba(59, 130, 246, 0.45) !important;
color: #dbeafe !important;
order: 4; /* Right position — same slot Shell used to occupy */
}
.btn-toolbar.btn-enter:hover,
.btn-toolbar.btn-enter:active {
background: rgba(37, 74, 122, 0.95) !important;
border-color: rgba(59, 130, 246, 0.7) !important;
color: #fff !important;
}
/* Hide case selector on mobile - simplified toolbar */
@@ -895,27 +920,12 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Simplified toolbar layout — Run, Shell, and Case */
/* Simplified toolbar layout — Run, Enter, and Case */
.toolbar-left .toolbar-group:first-child {
width: 100%;
gap: 8px;
}
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0 !important;
}
.btn-toolbar.btn-shell::after {
content: "Shell";
font-size: 0.65rem;
}
/* Mobile case button - visible on mobile */
.btn-toolbar.btn-case-mobile {
display: flex !important;
@@ -2226,6 +2236,68 @@ html.mobile-init .file-browser-panel {
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
the shared skin system and intentionally retain their original dark values
for the three dark skins above. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
background: var(--glass-bg);
border-color: var(--glass-border);
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
background: var(--control-bg);
border-color: var(--control-border);
color: var(--text-dim);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
background: linear-gradient(135deg, #174ea6, #4f46e5);
border-color: #315fc3;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
background: var(--floating-bg);
border-color: var(--control-border);
color: var(--text);
box-shadow: var(--elevated-shadow);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
color: var(--text-muted);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
background: var(--modal-backdrop);
}
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
(always loaded — covers iPad landscape where mobile.css doesn't load).
+1
View File
@@ -2267,6 +2267,7 @@ Object.assign(CodemanApp.prototype, {
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
+26 -1
View File
@@ -394,6 +394,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'shell') {
return await this.runShell();
}
return await this.runClaude();
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
@@ -500,10 +503,32 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
/** Send Enter to the active session (phone toolbar button).
*
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
* With local echo on (the mobile default) the characters you typed are still
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
* Enter branch (terminal-ui.js) is what flushes that pending text and only
* then sends \r. Send a bare \r instead and you submit an empty line while the
* typed text stays stranded on screen — which reads as "the button does
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
sendEnterKey() {
if (!this.activeSessionId) return;
const coreService = this.terminal?._core?.coreService;
if (coreService && typeof coreService.triggerDataEvent === 'function') {
coreService.triggerDataEvent('\r', true);
return;
}
// Fallback only if xterm's private core API moves: correct when local echo
// is off, and still better than doing nothing.
this.sendInput('\r');
},
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this._applyRunMode();
+2
View File
@@ -1866,6 +1866,8 @@ Object.assign(CodemanApp.prototype, {
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
+511 -176
View File
File diff suppressed because it is too large Load Diff
+112 -17
View File
@@ -40,11 +40,22 @@
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
};
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
function currentSkin() {
return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
}
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
const skin = currentSkin();
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
function currentSkinIsLight(skin = currentSkin()) {
return CODEMAN_LIGHT_SKINS.has(skin);
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
@@ -54,6 +65,7 @@
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
global.codemanCurrentSkinIsLight = currentSkinIsLight;
})(window);
Object.assign(CodemanApp.prototype, {
@@ -75,6 +87,7 @@ Object.assign(CodemanApp.prototype, {
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
@@ -969,8 +982,66 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Get line text - translateToString handles wrapped lines
const lineText = line.translateToString(true);
// Stitch the LOGICAL line back together.
//
// xterm invokes this provider per visible ROW, and translateToString returns
// that row alone (the old comment here claimed otherwise). A URL or path
// longer than the terminal is wide therefore matched only as far as the row
// boundary, and the link opened a PREFIX of the real target. Walk out to both
// ends of the continuation, match against the joined text, and map offsets
// back to (x, y) so a link can span rows.
//
// Two different kinds of continuation, and handling only the first is not
// enough:
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
// `isWrapped`.
// 2. HARD wrap: the program did its own wrapping and emitted a real
// newline, so nothing is flagged. Ink does this, which is why Claude
// Code's own `/login` URL was cut at the window edge, and why the
// clickable part grew when the window was widened.
// A row that fills the full width is treated as continuing into the next:
// that is the signal a hard wrap leaves behind, and a line that genuinely
// ended would stop short of the last column.
const cols = self.terminal.cols;
const rowAt = (r) => buffer.getLine(r - 1);
const continuesPrevious = (r) => {
if (r <= 1) return false;
if (rowAt(r)?.isWrapped) return true;
const prev = rowAt(r - 1);
return !!prev && prev.translateToString(true).length >= cols;
};
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12;
let startRow = bufferLineNumber;
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
startRow--;
}
let endRow = bufferLineNumber;
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
endRow++;
}
const rowTexts = [];
for (let r = startRow; r <= endRow; r++) {
const row = rowAt(r);
if (!row) break;
// Only the final row may be trimmed. Continuation rows fill the width by
// definition, and trimming one would shift every later offset.
rowTexts.push(row.translateToString(r === endRow));
}
const lineText = rowTexts.join('');
/** Map an offset in the stitched text back to a 1-based terminal cell. */
const coordAt = (index) => {
let rest = index;
for (let i = 0; i < rowTexts.length - 1; i++) {
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
rest -= rowTexts[i].length;
}
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
};
if (!lineText || !lineText.includes('/')) {
callback(undefined);
@@ -980,22 +1051,27 @@ Object.assign(CodemanApp.prototype, {
const links = [];
// Pattern 0: URLs (https://, http://) — matched first so they take priority
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
//
// A single `&` is PART of the URL: it separates query parameters, so excluding
// it truncated every real query string (`?post=1479&action=edit` linked only
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
// that is the shell operator and never appears inside a URL. A lone trailing
// `&` is trimmed below with the other trailing punctuation.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
const addUrlLink = (url, matchIndex) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)]+$/, '');
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
const startCol = lineText.indexOf(cleaned, matchIndex);
if (startCol === -1) return;
if (links.some((l) => l.range.start.x === startCol + 1)) return;
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
links.push({
text: cleaned,
range: {
start: { x: startCol + 1, y: bufferLineNumber },
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
},
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
window.open(text, '_blank', 'noopener,noreferrer');
@@ -1017,31 +1093,43 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions
// Pattern 2: Paths with common extensions.
// Image/PDF extensions are included so pasted-attachment paths
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
// rather than the log viewer (see addLink).
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
/** Extensions that should open the image/document preview, not the log viewer. */
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
const addLink = (filePath, matchIndex) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length);
// Skip if already have link at this position
if (links.some((l) => l.range.start.x === startCol + 1)) return;
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
links.push({
text: filePath,
range: {
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
},
range: { start, end }, // 1-based, may span wrapped rows
decorations: {
pointerCursor: true,
underline: true,
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images and PDFs inline.
const ext = (text.split('.').pop() || '').toLowerCase();
if (PREVIEW_EXTS.has(ext)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
self.openLogViewerWindow(text, self.activeSessionId);
},
hover() {
@@ -2890,8 +2978,14 @@ Object.assign(CodemanApp.prototype, {
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1;
if (this.terminal) {
this.terminal.options.minimumContrastRatio = minimumContrastRatio;
this.terminal.options.theme = theme;
// The zero-lag typing overlay caches the xterm foreground/background.
// Refresh it on live skin changes so typed text never keeps the prior
// theme's dark backing surface or foreground color.
this._localEchoOverlay?.refreshFont();
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
@@ -2899,6 +2993,7 @@ Object.assign(CodemanApp.prototype, {
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
+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();
},
});