mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge master into PR #156 (remote tmux durability)
Resolutions: - session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108) and add master's docker/owner fields to it - tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand (now an options object threading claudeMode/allowedTools into buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade) - case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery endpoint admin-only in multi-user mode (hosts are machine-level infra) - settings-ui.js: union of remoteAutoReconnect + master's header-button defaults - session-routes.ts: union of imports; session gets remote + owner Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
|
||||
* change-password flow. Self-contained (builds its own DOM) so it needs no
|
||||
* index.html surgery beyond the script tag and integrates with the existing App
|
||||
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
|
||||
*
|
||||
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
|
||||
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
|
||||
*
|
||||
* In single-user mode GET /api/me returns a synthetic admin with multiUser:false,
|
||||
* so none of the admin UI is shown and behavior is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body);
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await window.fetch(path, { headers: { Accept: 'application/json' } });
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
async function apiSend(method, path, body) {
|
||||
const res = await window.fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
/* empty body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, body: json, data: unwrap(json) };
|
||||
}
|
||||
|
||||
// ── Change-password modal ─────────────────────────────────────────────────
|
||||
let cpModal = null;
|
||||
function buildChangePasswordModal() {
|
||||
if (cpModal) return cpModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'changePasswordModal';
|
||||
el.style.zIndex = '3100';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<div class="modal-header"><h2>Change Password</h2></div>
|
||||
<div class="modal-body">
|
||||
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
|
||||
You must change your password before continuing.</p>
|
||||
<div class="form-row"><label>Current password</label>
|
||||
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
|
||||
<div class="form-row"><label>New password (min 8)</label>
|
||||
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
|
||||
<div class="form-row"><label>Confirm new password</label>
|
||||
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
|
||||
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cpCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="cpSubmit">Change password</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none');
|
||||
el.querySelector('#cpSubmit').onclick = async () => {
|
||||
const current = el.querySelector('#cpCurrent').value;
|
||||
const nw = el.querySelector('#cpNew').value;
|
||||
const confirm = el.querySelector('#cpConfirm').value;
|
||||
const err = el.querySelector('#cpError');
|
||||
err.textContent = '';
|
||||
if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.');
|
||||
if (nw !== confirm) return (err.textContent = 'Passwords do not match.');
|
||||
const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw });
|
||||
if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.');
|
||||
el.style.display = 'none';
|
||||
if (window.app && window.app.showToast) window.app.showToast('Password changed');
|
||||
};
|
||||
cpModal = el;
|
||||
return el;
|
||||
}
|
||||
function openChangePassword(forced) {
|
||||
const el = buildChangePasswordModal();
|
||||
el.querySelector('#cpMustNote').style.display = forced ? '' : 'none';
|
||||
el.querySelector('#cpCancel').style.display = forced ? 'none' : '';
|
||||
el.querySelector('#cpError').textContent = '';
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ───────────────────
|
||||
function installInterceptor() {
|
||||
const orig = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await orig.apply(this, args);
|
||||
if (res.status === 403) {
|
||||
try {
|
||||
const clone = res.clone();
|
||||
const j = await clone.json();
|
||||
if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin Users panel (injected into the App Settings modal) ──────────────
|
||||
function injectUsersTab() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
|
||||
const tabs = modal.querySelector('.modal-tabs');
|
||||
const body = modal.querySelector('.modal-body');
|
||||
if (!tabs || !body) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'modal-tab-btn';
|
||||
btn.dataset.tab = 'settings-users';
|
||||
btn.textContent = 'Users';
|
||||
tabs.appendChild(btn);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-tab-content hidden';
|
||||
content.id = 'settings-users';
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
|
||||
body.appendChild(content);
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
const table = document.getElementById('adminUsersTable');
|
||||
if (!table) return;
|
||||
table.innerHTML = 'Loading…';
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const flags = [
|
||||
u.role === 'admin' ? 'admin' : 'user',
|
||||
u.disabled ? 'disabled' : 'enabled',
|
||||
u.canBypassPermissions ? 'can-bypass' : '',
|
||||
u.mustChangePassword ? 'must-change-pw' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const st = u.stats || {};
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td>${esc(u.username)}</td>
|
||||
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
|
||||
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
|
||||
<button class="btn btn-xs" data-act="reset">Reset pw</button>
|
||||
<button class="btn btn-xs" data-act="delete">Delete</button>
|
||||
</td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
|
||||
<thead><tr><th align="left">User</th><th align="left">Flags</th><th align="left">Usage</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
b.onclick = () =>
|
||||
userAction(
|
||||
b.closest('tr').dataset.u,
|
||||
b.dataset.act,
|
||||
users.find((x) => x.username === b.closest('tr').dataset.u)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setMsg(t) {
|
||||
const m = document.getElementById('adminUsersMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
async function userAction(username, act, u) {
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
role: u.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled });
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
canBypassPermissions: !u.canBypassPermissions,
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'reset') {
|
||||
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
|
||||
const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'delete') {
|
||||
const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`);
|
||||
if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.');
|
||||
const deleteSpace = typed.endsWith(' +space');
|
||||
const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace });
|
||||
setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
async function addUserFlow() {
|
||||
const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):');
|
||||
if (!username) return;
|
||||
const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)');
|
||||
const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' });
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Create failed.');
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
installInterceptor();
|
||||
let me = null;
|
||||
try {
|
||||
me = await apiGet('/api/me');
|
||||
} catch {
|
||||
/* server may be pre-auth */
|
||||
}
|
||||
window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false };
|
||||
document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser }));
|
||||
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
|
||||
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
|
||||
injectUsersTab();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers };
|
||||
})();
|
||||
@@ -1437,6 +1437,69 @@ class CodemanApp {
|
||||
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
||||
addListener(event, () => this._onSessionListMaybeChanged());
|
||||
}
|
||||
|
||||
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
|
||||
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success');
|
||||
this.refreshDockerExports?.();
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker export complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker export failed:', err);
|
||||
}
|
||||
});
|
||||
// Import + drift-recreate completions: refresh case lists in EVERY open tab
|
||||
// (the initiating tab already refreshes via its own fetch response).
|
||||
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
|
||||
this.loadQuickStartCases?.();
|
||||
this.refreshDockerExports?.();
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker import complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker container recreated:', err);
|
||||
}
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
|
||||
duration: 8000,
|
||||
});
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
|
||||
else this.showToast('Agent image ready. Starting the container...', 'success');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -479,6 +479,17 @@ const SSE_EVENTS = {
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
// Multi-user (admin-only / targeted)
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+143
-4
@@ -118,12 +118,13 @@
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><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"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><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="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><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"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><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="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><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"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<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="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><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"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><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"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -557,7 +558,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1268,6 +1269,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1282,6 +1290,27 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)">
|
||||
<span class="settings-item-label">Session Manager Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowSessionButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the away digest button in the header (opens the 'what happened while you were away' summary)">
|
||||
<span class="settings-item-label">Away Digest Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAwayDigestButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the Cron button in the footer toolbar (opens the cron jobs manager)">
|
||||
<span class="settings-item-label">Cron Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCronButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1420,10 +1449,11 @@
|
||||
<label>Startup Mode</label>
|
||||
<select id="appSettingsClaudeMode" class="form-select">
|
||||
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
||||
<option value="auto">Auto (classifier-guarded, low prompts)</option>
|
||||
<option value="normal">Normal (with prompts)</option>
|
||||
<option value="allowedTools">Allowed Tools Only</option>
|
||||
</select>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions</span>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
|
||||
</div>
|
||||
<div class="form-row" id="allowedToolsRow" style="display: none;">
|
||||
<label>Allowed Tools</label>
|
||||
@@ -1836,6 +1866,7 @@
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1850,6 +1881,53 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
|
||||
<option value="small">Small — 2 GB RAM, 1 CPU</option>
|
||||
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
|
||||
<option value="large">Large — 8 GB RAM, 4 CPU</option>
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>GPUs</label>
|
||||
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="quickDockerNetwork">
|
||||
<option value="bridge">bridge (internet on)</option>
|
||||
<option value="none">none (fully isolated)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Link Existing Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-link">
|
||||
@@ -1935,12 +2013,72 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Runs inside an isolated container. Multiple sessions can share the same container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
<option value="none">none (fully isolated, no network)</option>
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced container settings</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="dockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Optional, e.g. 4g / 512m. Enforced as a hard OOM cap where the engine supports it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="dockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerMountCredentials" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
<span class="form-hint">On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerResumeOnStart" checked> Resume last conversation on relaunch</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<span class="form-hint" id="dockerLinkStatus" style="margin-top: 8px; display: block;"></span>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
<div id="dockerExportsSection" style="margin-top: 16px; border-top: 1px solid var(--border, #333); padding-top: 12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
|
||||
<strong style="font-size: 13px;">Docker exports</strong>
|
||||
<button class="btn-toolbar" onclick="app.refreshDockerExports()">Refresh</button>
|
||||
</div>
|
||||
<div class="case-manage-list" id="dockerExportsList"><span class="form-hint">No exports yet. Export a docker case from its tab.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -2376,6 +2514,7 @@
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear, lifecycle log, away digest, and session manager on
|
||||
mobile - settings moved to toolbar; away digest and the session manager are
|
||||
secondary controls that don't belong on the cramped phone header (the session
|
||||
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
|
||||
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
|
||||
item; the file viewer button is opt-in but its panel is desktop-sized).
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log,
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager {
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -3125,6 +3125,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -3157,6 +3183,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
|
||||
+345
-38
@@ -45,7 +45,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
formatCasePickerLabel(c) {
|
||||
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
|
||||
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
|
||||
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
|
||||
return c?.name || '';
|
||||
},
|
||||
|
||||
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
|
||||
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
|
||||
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
|
||||
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
|
||||
dockerCaseTag(hostId) {
|
||||
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
|
||||
return hostId;
|
||||
},
|
||||
|
||||
buildCasePickerOptions(cases = []) {
|
||||
@@ -70,7 +81,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
c.location,
|
||||
c.remote?.hostId,
|
||||
c.remote?.label,
|
||||
c.remote?.path
|
||||
c.remote?.path,
|
||||
c.docker?.container,
|
||||
c.docker?.image,
|
||||
c.docker?.path
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return { name: c.name, label, case: c, searchText };
|
||||
})
|
||||
@@ -480,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
// w2-<case> for agents, s1-<case> for shells), shared by the local and
|
||||
// remote/docker launch paths so all tabs follow the same naming convention.
|
||||
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
|
||||
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
const match = session.name && session.name.match(re);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
@@ -517,15 +547,54 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote') {
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
||||
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
// Docker (NOT remote): the App Settings Claude Model choice applies — the
|
||||
// workspace is a real host dir, so quick-start writes it to the case's
|
||||
// .claude/settings.local.json and the in-container claude reads it.
|
||||
// Remote quick-starts REJECT modelOverride (the file would land on the
|
||||
// wrong machine), so never send it there.
|
||||
let dockerModelOverride;
|
||||
if (caseData.location === 'docker') {
|
||||
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
|
||||
const dockerCaseSettings = this.getCaseSettings(caseName);
|
||||
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
|
||||
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
|
||||
}
|
||||
const remoteIds = [];
|
||||
let driftHandled = false;
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
const quickStartBody = JSON.stringify({
|
||||
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
||||
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
||||
});
|
||||
const data = await res.json();
|
||||
const doQuickStart = async () => {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: quickStartBody
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
let data = await doQuickStart();
|
||||
// Docker config drift: the host config changed since the container was
|
||||
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
|
||||
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
|
||||
driftHandled = true;
|
||||
const recreate = confirm(
|
||||
`Container config for "${caseName}" changed since its container was created.\n\n` +
|
||||
'Recreate the container to apply the new config? Workspace files and the ' +
|
||||
'conversation survive (the conversation resumes on launch).'
|
||||
);
|
||||
if (recreate) {
|
||||
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
|
||||
const recData = await recRes.json();
|
||||
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
|
||||
data = await doQuickStart();
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
@@ -541,16 +610,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -694,18 +754,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
|
||||
const isRemoteCase =
|
||||
caseData.location === 'remote' ||
|
||||
caseData.location === 'docker' ||
|
||||
selectedCase?.location === 'remote' ||
|
||||
selectedCase?.location === 'docker';
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote') {
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
@@ -721,16 +786,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
@@ -789,7 +845,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
@@ -818,6 +875,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -843,7 +901,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
@@ -869,6 +928,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
@@ -897,7 +957,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
@@ -922,6 +983,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1567,10 +1629,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
this.renderCaseManageList();
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent =
|
||||
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
|
||||
tabName === 'case-create'
|
||||
? 'Create'
|
||||
: tabName === 'case-remote'
|
||||
? 'Link Remote'
|
||||
: tabName === 'case-docker'
|
||||
? 'Link Docker'
|
||||
: 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
@@ -1579,6 +1648,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
} else if (tabName === 'case-remote') {
|
||||
document.getElementById('remoteCaseName').focus();
|
||||
} else if (tabName === 'case-docker') {
|
||||
document.getElementById('dockerCaseName').focus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1596,6 +1667,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.createCase();
|
||||
} else if (this.caseModalTab === 'case-remote') {
|
||||
await this.linkRemoteCase();
|
||||
} else if (this.caseModalTab === 'case-docker') {
|
||||
await this.linkDockerCase();
|
||||
} else {
|
||||
await this.linkCase();
|
||||
}
|
||||
@@ -1619,21 +1692,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click "Run in Docker": create the case folder AND a container, then start
|
||||
// a session inside it. Optional expandable settings override the defaults.
|
||||
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
||||
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
||||
const payload = inDocker
|
||||
? { name, description, ...this._collectDockerQuickSettings() }
|
||||
: { name, description };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/cases', {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
// Reload cases and select the new one
|
||||
await this.loadQuickStartCases(name);
|
||||
// Save as last used case
|
||||
await this.saveLastUsedCase(name);
|
||||
if (inDocker) {
|
||||
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
|
||||
// Start a session INSIDE the container (routes through quick-start).
|
||||
await this.runClaude();
|
||||
} else {
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
}
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to create case', 'error');
|
||||
}
|
||||
@@ -1643,6 +1731,47 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
|
||||
// the server uses its defaults (no per-case host); `custom` leaves them editable.
|
||||
applyDockerTemplate() {
|
||||
const t = document.getElementById('quickDockerTemplate')?.value;
|
||||
const presets = {
|
||||
small: { m: '2g', c: '1', g: '' },
|
||||
medium: { m: '', c: '', g: '' },
|
||||
large: { m: '8g', c: '4', g: '' },
|
||||
gpu: { m: '8g', c: '4', g: 'all' },
|
||||
};
|
||||
const p = presets[t];
|
||||
if (!p) return; // 'custom' — leave fields as-is
|
||||
const set = (id, v) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
};
|
||||
set('quickDockerMemory', p.m);
|
||||
set('quickDockerCpus', p.c);
|
||||
set('quickDockerGpus', p.g);
|
||||
},
|
||||
|
||||
// Collect only the non-default docker overrides (empty fields fall back to defaults
|
||||
// server-side; sent as undefined, never null, per the Zod .optional() gotcha).
|
||||
_collectDockerQuickSettings() {
|
||||
const val = (id) => (document.getElementById(id)?.value || '').trim();
|
||||
const o = {};
|
||||
const mem = val('quickDockerMemory');
|
||||
if (mem) o.memory = mem;
|
||||
const cpus = val('quickDockerCpus');
|
||||
if (cpus) o.cpus = cpus;
|
||||
const gpus = val('quickDockerGpus');
|
||||
if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus;
|
||||
const net = document.getElementById('quickDockerNetwork')?.value;
|
||||
if (net && net !== 'bridge') o.network = net;
|
||||
const img = val('quickDockerImage');
|
||||
if (img) o.image = img;
|
||||
const mc = document.getElementById('quickDockerMountCreds');
|
||||
if (mc && !mc.checked) o.mountCredentials = false;
|
||||
return o;
|
||||
},
|
||||
|
||||
async linkCase() {
|
||||
const name = document.getElementById('linkCaseName').value.trim();
|
||||
const path = document.getElementById('linkCasePath').value.trim();
|
||||
@@ -1767,6 +1896,178 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async linkDockerCase() {
|
||||
const name = document.getElementById('dockerCaseName').value.trim();
|
||||
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
||||
const network = document.getElementById('dockerNetwork').value;
|
||||
const memory = document.getElementById('dockerMemory').value.trim();
|
||||
const cpus = document.getElementById('dockerCpus').value.trim();
|
||||
const mountCredentials = document.getElementById('dockerMountCredentials').checked;
|
||||
const resumeOnStart = document.getElementById('dockerResumeOnStart').checked;
|
||||
const statusEl = document.getElementById('dockerLinkStatus');
|
||||
|
||||
if (!name || !hostWorkspacePath) {
|
||||
this.showToast('Please enter a case name and workspace path', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
||||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!hostWorkspacePath.startsWith('/')) {
|
||||
this.showToast('Workspace path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
||||
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
||||
const resources = {};
|
||||
if (memory) resources.memory = memory;
|
||||
if (cpus) resources.cpus = cpus;
|
||||
const hostPayload = {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
image,
|
||||
network,
|
||||
mountCredentials,
|
||||
resumeOnStart,
|
||||
...(Object.keys(resources).length ? { resources } : {}),
|
||||
};
|
||||
// PUT (update-or-create) so re-linking with the same host id refreshes its settings.
|
||||
let hostRes = await fetch('/api/docker-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload),
|
||||
});
|
||||
let hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') {
|
||||
hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload),
|
||||
});
|
||||
hostData = await hostRes.json();
|
||||
}
|
||||
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
||||
|
||||
const caseRes = await fetch('/api/cases/docker-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case';
|
||||
this.showToast(caseData.error || 'Failed to link docker case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to link docker case:', err);
|
||||
if (statusEl) statusEl.textContent = err.message;
|
||||
this.showToast('Failed to link docker case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Docker export / import UI
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async refreshDockerExports() {
|
||||
const listEl = document.getElementById('dockerExportsList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const res = await fetch('/api/docker-exports');
|
||||
const data = await res.json();
|
||||
const exports = data?.data?.exports || [];
|
||||
if (exports.length === 0) {
|
||||
listEl.innerHTML = '<span class="form-hint">No exports yet. Export a docker case from its tab.</span>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = exports
|
||||
.map(e => {
|
||||
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
||||
// escapeHtml is the free function from constants.js (never a method on `this`)
|
||||
const nm = escapeHtml(e.name);
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<span class="form-hint">Failed to load exports: ${err.message}</span>`;
|
||||
}
|
||||
},
|
||||
|
||||
async exportDockerCaseBundle(caseName, mode = 'full') {
|
||||
try {
|
||||
const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info');
|
||||
} else {
|
||||
this.showToast(data.error || 'Export failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Export failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async importDockerBundle(bundle) {
|
||||
const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported');
|
||||
if (!newCaseName) return;
|
||||
const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', '');
|
||||
if (!destWorkspacePath) return;
|
||||
try {
|
||||
const res = await fetch('/api/docker-cases/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Imported as "${newCaseName}"`, 'success');
|
||||
await this.loadQuickStartCases(newCaseName);
|
||||
} else {
|
||||
this.showToast(data.error || 'Import failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Import failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDockerExport(filename) {
|
||||
if (!confirm(`Delete export bundle "${filename}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast('Export deleted', 'success');
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
this.showToast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COD-105 — Discover + attach existing remote tmux sessions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1941,6 +2242,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
${
|
||||
c.location === 'docker'
|
||||
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
|
||||
title="Export container (full image + workspace) to move to another machine">📦</button>`
|
||||
: ''
|
||||
}
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
|
||||
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
@@ -324,6 +325,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1423,6 +1428,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
@@ -1433,6 +1439,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1613,8 +1622,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1771,7 +1786,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: true,
|
||||
// Remote auto-reconnect (COD-108) — on by default
|
||||
remoteAutoReconnect: true,
|
||||
// Input
|
||||
@@ -1886,6 +1905,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1921,6 +1948,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
|
||||
}
|
||||
|
||||
// Session Manager button — opt-in, hidden by default (App Settings → Display).
|
||||
// Marker class (base is display:inline-flex !important); phones keep it hidden
|
||||
// via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette.
|
||||
const showSessionButton = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
const sessionBtn = document.querySelector('.btn-session-manager');
|
||||
if (sessionBtn) {
|
||||
sessionBtn.classList.toggle('btn-session-manager--hidden', !showSessionButton);
|
||||
}
|
||||
|
||||
// Away Digest button — opt-in, hidden by default. Same marker pattern.
|
||||
const showAwayDigestButton = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
const awayDigestBtn = document.querySelector('.btn-away-digest');
|
||||
if (awayDigestBtn) {
|
||||
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
|
||||
}
|
||||
|
||||
// Cron button (footer toolbar) — shown by default; hide when disabled.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
const cronBtn = document.querySelector('.btn-cron');
|
||||
if (cronBtn) {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -2157,8 +2207,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
|
||||
@@ -5414,6 +5414,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
/* Modal Tabs */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -9478,6 +9479,24 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* "Run in Docker" quick option — the primary one-click entry point, so its
|
||||
label + hint read larger and brighter than the standard form label/hint. */
|
||||
.docker-quick-row .checkbox-row {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.docker-quick-row .form-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* The container-settings panel is always shown + expanded (no longer gated on
|
||||
the checkbox), so its summary header reads a touch larger too. */
|
||||
.docker-quick-settings > summary {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -9502,6 +9521,42 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Session Manager" + "Away Digest" header buttons — opt-in (App Settings →
|
||||
Display), hidden by default. Same marker pattern as the response viewer: a
|
||||
base inline-flex !important so an inline style can't override it, and a
|
||||
more-specific marker rule to hide. Phones keep them hidden regardless via the
|
||||
higher-specificity mobile.css rule (.btn-icon-header.btn-...). */
|
||||
.btn-session-manager {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-session-manager.btn-session-manager--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.btn-away-digest {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-away-digest.btn-away-digest--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
|
||||
by default. Clicking it toggles the file browser panel. Same marker pattern as
|
||||
the response viewer: a base inline-flex !important so an inline style can't
|
||||
override it, and a more-specific marker rule to hide. */
|
||||
.btn-file-viewer {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-file-viewer.btn-file-viewer--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
|
||||
hide it). Toolbar button, not a header icon, so only the hide marker is
|
||||
needed; out-specify any base .btn-toolbar display. */
|
||||
.btn-toolbar.btn-cron--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
|
||||
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
|
||||
Base inline-flex !important + a more-specific marker rule to hide. */
|
||||
|
||||
Reference in New Issue
Block a user