mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Replaces the size-scaled expected-time estimate + matching auto-timeout with a generic hardware/model-size disclaimer and a user-driven Cancel button, per explicit request. Real load time depends on hardware this feature has no way to know (VRAM, storage speed, GPU contention), so the old estimate/timeout was a guess dressed up as a fact — worse, one that could kill a genuinely slow load partway through on slower hardware. - _watchLlamaSwapLoading (session-ui.js): dropped maxWaitMs/deadline entirely — polls indefinitely until ready or cancelled, no automatic give-up. Message is now "Loading <model> (<size>) on <endpoint> — this can take a while depending on your hardware and the model size.", with the real llama.cpp log line still on its own second line. Removed _MODEL_LOAD_TIME_MATRIX/_estimateModelLoad/ _formatRemaining (dead code once the countdown is gone) — _lookupModelSizeGB is kept, the GB figure still shows. - _showCenterStatus (panels-ui.js) gains opts.onCancel: renders a real "Cancel" button (distinct from the error-type "×" close button, since Cancel has a real consequence) that calls it on click. Caller owns what cancelling actually means, same split as the swap-confirm modal's promise-resolving buttons. - Cancelling dismisses the banner, shows an info toast (not an error — this was deliberate), and closes the session, mirroring what the old timeout used to do automatically but now on the user's own call. - New .center-status-cancel CSS (bordered pill button, distinct from the plain "×" close glyph). Test changes: removed the now-invalid timeout-auto-close/estimate tests, added cancel-flow tests (dismiss/toast-type/session-close, never-closes-with-no-sessionId, unbounded-polling), and real-DOM tests for the new Cancel button (bootAppWithRealCenterStatus, evaluating panels-ui.js instead of stubbing _showCenterStatus, since this button is worth verifying for real rather than just through the stub every other test in the file uses). Typecheck/lint/frontend-syntax clean; full suite shows no new regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
4584 lines
202 KiB
JavaScript
4584 lines
202 KiB
JavaScript
/**
|
|
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
|
|
* session options modal (per-session settings, color picker, rename),
|
|
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
|
* create case modal, and mobile case picker.
|
|
*
|
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
|
* @dependency app.js (CodemanApp class, this.sessions, this.cases, this.activeSessionId)
|
|
* @dependency constants.js (escapeHtml)
|
|
* @dependency mobile-handlers.js (MobileDetection)
|
|
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
|
*/
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
/**
|
|
* Build envOverrides payload from case + global settings.
|
|
* Single source of truth for the server-side tmux setenv values.
|
|
* Keys omitted when value is default/falsy — backend treats unset as "no override".
|
|
*/
|
|
buildEnvOverrides(caseSettings, globalSettings) {
|
|
const env = {};
|
|
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
|
|
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
|
}
|
|
// NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL —
|
|
// the env var hard-locks effort and blocks in-session /effort switching (e.g.,
|
|
// ultracode). It flows as the dedicated `effort` payload field instead, which the
|
|
// backend injects as a `--settings` soft default. See getEffortSetting().
|
|
return env;
|
|
},
|
|
|
|
/**
|
|
* Resolve the effort level for new sessions from global settings.
|
|
* Returns a valid effort string or undefined (= no override, CLI default).
|
|
* Sent as the `effort` payload field — backend turns it into `claude --settings ...`.
|
|
*/
|
|
getEffortSetting(globalSettings) {
|
|
const effort = globalSettings?.thinkingEffort;
|
|
const valid = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
|
|
return valid.includes(effort) ? effort : undefined;
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Quick Start
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
formatCasePickerLabel(c) {
|
|
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 = []) {
|
|
const normalized = [];
|
|
const seen = new Set();
|
|
for (const c of cases) {
|
|
if (!c?.name || seen.has(c.name)) continue;
|
|
seen.add(c.name);
|
|
normalized.push(c);
|
|
}
|
|
if (!seen.has('testcase')) {
|
|
normalized.push({ name: 'testcase' });
|
|
}
|
|
|
|
return normalized
|
|
.map(c => {
|
|
const label = this.formatCasePickerLabel(c);
|
|
const searchText = [
|
|
c.name,
|
|
label,
|
|
c.path,
|
|
c.location,
|
|
c.remote?.hostId,
|
|
c.remote?.label,
|
|
c.remote?.path,
|
|
c.docker?.container,
|
|
c.docker?.image,
|
|
c.docker?.path
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
return { name: c.name, label, case: c, searchText };
|
|
})
|
|
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
|
|
},
|
|
|
|
filterCasePickerOptions(options, query) {
|
|
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (terms.length === 0) return options;
|
|
return options.filter(option => terms.every(term => option.searchText.includes(term)));
|
|
},
|
|
|
|
getCasePickerOptions() {
|
|
return this.buildCasePickerOptions(this.cases || []);
|
|
},
|
|
|
|
updateCasePickerInput(caseName) {
|
|
const input = document.getElementById('quickStartCaseSearch');
|
|
if (!input) return;
|
|
const option = this.getCasePickerOptions().find(item => item.name === caseName);
|
|
input.value = option?.label || caseName || 'testcase';
|
|
input.title = option?.label || input.value;
|
|
},
|
|
|
|
renderQuickStartCaseSelectOptions(select, options) {
|
|
if (!select) return;
|
|
select.innerHTML = options
|
|
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
|
|
.join('');
|
|
},
|
|
|
|
openCasePicker(filter = '') {
|
|
const input = document.getElementById('quickStartCaseSearch');
|
|
const list = document.getElementById('quickStartCaseList');
|
|
if (!input || !list) return;
|
|
this._casePickerOpen = true;
|
|
this._casePickerFilter = filter;
|
|
this._casePickerActiveIndex = 0;
|
|
input.setAttribute('aria-expanded', 'true');
|
|
this.renderCasePickerList();
|
|
},
|
|
|
|
closeCasePicker() {
|
|
const input = document.getElementById('quickStartCaseSearch');
|
|
const list = document.getElementById('quickStartCaseList');
|
|
this._casePickerOpen = false;
|
|
this._casePickerFilter = '';
|
|
input?.setAttribute('aria-expanded', 'false');
|
|
input?.removeAttribute('aria-activedescendant');
|
|
list?.classList.add('hidden');
|
|
},
|
|
|
|
renderCasePickerList() {
|
|
const input = document.getElementById('quickStartCaseSearch');
|
|
const list = document.getElementById('quickStartCaseList');
|
|
const select = document.getElementById('quickStartCase');
|
|
if (!input || !list || !select) return;
|
|
|
|
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
|
|
const selectedName = select.value || 'testcase';
|
|
const maxIndex = Math.max(0, options.length - 1);
|
|
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
|
|
|
|
if (options.length === 0) {
|
|
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
|
|
list.classList.remove('hidden');
|
|
input.removeAttribute('aria-activedescendant');
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = options
|
|
.map((option, index) => {
|
|
const active = index === this._casePickerActiveIndex;
|
|
const selected = option.name === selectedName;
|
|
const id = `quickStartCaseOption-${index}`;
|
|
return `
|
|
<button
|
|
type="button"
|
|
id="${id}"
|
|
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
|
|
role="option"
|
|
aria-selected="${selected ? 'true' : 'false'}"
|
|
data-case="${escapeHtml(option.name)}"
|
|
title="${escapeHtml(option.label)}">
|
|
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
|
|
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
|
|
</button>
|
|
`;
|
|
})
|
|
.join('');
|
|
list.classList.remove('hidden');
|
|
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
|
|
},
|
|
|
|
selectQuickStartCase(caseName, { save = true } = {}) {
|
|
const select = document.getElementById('quickStartCase');
|
|
if (!select) return;
|
|
select.value = caseName || 'testcase';
|
|
this.updateCasePickerInput(select.value);
|
|
this.closeCasePicker();
|
|
this.updateDirDisplayForCase(select.value);
|
|
this.updateMobileCaseLabel(select.value);
|
|
// Warm the container's CLI list HERE rather than when the run menu opens.
|
|
// The probe is a `docker exec` round trip, so gating it on the menu meant the
|
|
// menu painted every mode first and only narrowed a moment later — which
|
|
// reads as "it shows all of them" and lets a mode be picked that the
|
|
// container does not have.
|
|
const picked = (this.cases || []).find((c) => c.name === select.value);
|
|
if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null);
|
|
if (save) {
|
|
this.saveLastUsedCase(select.value);
|
|
}
|
|
},
|
|
|
|
setupQuickStartCasePicker() {
|
|
const select = document.getElementById('quickStartCase');
|
|
const input = document.getElementById('quickStartCaseSearch');
|
|
const list = document.getElementById('quickStartCaseList');
|
|
const picker = document.getElementById('quickStartCasePicker');
|
|
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
|
|
|
|
input.addEventListener('focus', () => {
|
|
input.select?.();
|
|
this.openCasePicker('');
|
|
});
|
|
input.addEventListener('click', () => {
|
|
input.select?.();
|
|
this.openCasePicker('');
|
|
});
|
|
input.addEventListener('input', () => {
|
|
this.openCasePicker(input.value);
|
|
});
|
|
input.addEventListener('keydown', event => {
|
|
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
|
|
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
|
|
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
|
} else if (event.key === 'Enter') {
|
|
const option = options[this._casePickerActiveIndex || 0];
|
|
if (option) {
|
|
event.preventDefault();
|
|
this.selectQuickStartCase(option.name);
|
|
this.run?.();
|
|
}
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
this.updateCasePickerInput(select.value);
|
|
this.closeCasePicker();
|
|
} else if (event.key === 'Tab') {
|
|
this.updateCasePickerInput(select.value);
|
|
this.closeCasePicker();
|
|
}
|
|
});
|
|
list.addEventListener('mousedown', event => event.preventDefault());
|
|
list.addEventListener('click', event => {
|
|
const option = event.target.closest?.('.case-combobox-option');
|
|
if (option?.dataset?.case) {
|
|
this.selectQuickStartCase(option.dataset.case);
|
|
}
|
|
});
|
|
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
|
|
document.addEventListener('pointerdown', event => {
|
|
if (!picker.contains(event.target)) {
|
|
this.updateCasePickerInput(select.value);
|
|
this.closeCasePicker();
|
|
}
|
|
});
|
|
this._casePickerDocumentListenerAdded = true;
|
|
}
|
|
input.dataset.listenerAdded = 'true';
|
|
},
|
|
|
|
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
|
|
try {
|
|
// Load settings to get lastUsedCase (reuse shared promise if provided)
|
|
let lastUsedCase = null;
|
|
try {
|
|
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null);
|
|
if (settings) {
|
|
lastUsedCase = settings.lastUsedCase || null;
|
|
}
|
|
} catch {
|
|
// Ignore settings load errors
|
|
}
|
|
|
|
const res = await fetch('/api/cases');
|
|
const cases = (await res.json()).data;
|
|
this.cases = cases;
|
|
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
|
|
|
|
const select = document.getElementById('quickStartCase');
|
|
|
|
const options = this.getCasePickerOptions();
|
|
this.renderQuickStartCaseSelectOptions(select, options);
|
|
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
|
|
|
|
// If a specific case was requested, select it
|
|
if (selectCaseName) {
|
|
select.value = selectCaseName;
|
|
this.updateDirDisplayForCase(selectCaseName);
|
|
this.updateMobileCaseLabel(selectCaseName);
|
|
} else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) {
|
|
// Use lastUsedCase if available and exists
|
|
select.value = lastUsedCase;
|
|
this.updateDirDisplayForCase(lastUsedCase);
|
|
this.updateMobileCaseLabel(lastUsedCase);
|
|
} else if (cases.length > 0) {
|
|
// Fallback to testcase or first case
|
|
const firstCase = cases.find(c => c.name === 'testcase') || cases[0];
|
|
select.value = firstCase.name;
|
|
this.updateDirDisplayForCase(firstCase.name);
|
|
this.updateMobileCaseLabel(firstCase.name);
|
|
} else {
|
|
// No cases exist yet - show the default case name as directory
|
|
select.value = 'testcase';
|
|
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
|
|
this.updateMobileCaseLabel('testcase');
|
|
}
|
|
this.updateCasePickerInput(select.value);
|
|
this.renderCasePickerList();
|
|
this.closeCasePicker();
|
|
|
|
// Only add event listener once (on first load)
|
|
if (!select.dataset.listenerAdded) {
|
|
select.addEventListener('change', () => {
|
|
this.updateDirDisplayForCase(select.value);
|
|
this.saveLastUsedCase(select.value);
|
|
this.updateMobileCaseLabel(select.value);
|
|
this.updateCasePickerInput(select.value);
|
|
});
|
|
select.dataset.listenerAdded = 'true';
|
|
}
|
|
this.setupQuickStartCasePicker();
|
|
// The phone overview labels rows with their case name, and a case rename or
|
|
// link does not go through the session-tab renderer.
|
|
this._refreshMobileOverviewIfVisible?.();
|
|
} catch (err) {
|
|
console.error('Failed to load cases:', err);
|
|
}
|
|
},
|
|
|
|
async updateDirDisplayForCase(caseName) {
|
|
try {
|
|
const res = await fetch(`/api/cases/${caseName}`);
|
|
const data = (await res.json()).data;
|
|
if (data.path) {
|
|
document.getElementById('dirDisplay').textContent = data.path;
|
|
document.getElementById('dirInput').value = data.path;
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('dirDisplay').textContent = caseName;
|
|
}
|
|
},
|
|
|
|
async saveLastUsedCase(caseName) {
|
|
try {
|
|
await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ lastUsedCase: caseName })
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to save last used case:', err);
|
|
}
|
|
},
|
|
|
|
async quickStart() {
|
|
return this.run();
|
|
},
|
|
|
|
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
|
* The POST response and session:created can arrive in either order, so the
|
|
* normal idempotent SSE handler remains the single state-upsert path. */
|
|
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
|
if (!sessionId) return;
|
|
|
|
let session = sessionSnapshot;
|
|
if (!session && !this.sessions?.has(sessionId)) {
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
|
|
session = data.data?.session || data.data;
|
|
}
|
|
|
|
if (session?.id) this._onSessionCreated(session);
|
|
// session:created normally uses the debounced renderer. The direct POST path
|
|
// needs the tab in the DOM before selectSession() marks it active.
|
|
this._renderSessionTabsImmediate?.();
|
|
},
|
|
|
|
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
|
|
async run() {
|
|
if (this._runInFlight) return;
|
|
|
|
const startedAt = Date.now();
|
|
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
|
|
const runBtn = document.getElementById('runBtn');
|
|
this._runInFlight = true;
|
|
if (runBtn) {
|
|
runBtn.disabled = true;
|
|
runBtn.setAttribute('aria-busy', 'true');
|
|
}
|
|
|
|
try {
|
|
const mode = this._runMode || 'claude';
|
|
if (mode === 'opencode') {
|
|
return await this.runOpenCode();
|
|
}
|
|
if (mode === 'codex') {
|
|
return await this.runCodex();
|
|
}
|
|
if (mode === 'gemini') {
|
|
return await this.runGemini();
|
|
}
|
|
if (mode === 'antigravity') {
|
|
return await this.runAntigravity();
|
|
}
|
|
if (mode === 'omp') {
|
|
return await this.runOmp();
|
|
}
|
|
if (mode === 'pi') {
|
|
return await this.runPi();
|
|
}
|
|
if (mode === 'grok') {
|
|
return await this.runGrok();
|
|
}
|
|
if (mode === 'deepseek') {
|
|
return await this.runDeepSeek();
|
|
}
|
|
if (mode === 'shell') {
|
|
return await this.runShell();
|
|
}
|
|
return await this.runClaude();
|
|
} finally {
|
|
const remaining = minLockMs - (Date.now() - startedAt);
|
|
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
|
this._runInFlight = false;
|
|
if (runBtn) {
|
|
runBtn.disabled = false;
|
|
runBtn.removeAttribute('aria-busy');
|
|
}
|
|
}
|
|
},
|
|
|
|
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
|
// this file — an object-literal getter here would be flattened to a static value by
|
|
// Object.assign (it copies values, not accessor descriptors).
|
|
|
|
setRunMode(mode) {
|
|
this._runMode = mode;
|
|
try { localStorage.setItem('codeman_runMode', mode); } catch {}
|
|
this._applyRunMode();
|
|
// Sync to server for cross-device persistence
|
|
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
|
|
// Close menu
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
},
|
|
|
|
toggleRunModeMenu(e) {
|
|
e?.stopPropagation();
|
|
const menu = document.getElementById('runModeMenu');
|
|
if (!menu) return;
|
|
menu.classList.toggle('active');
|
|
// Update selected state
|
|
menu.querySelectorAll('.run-mode-option').forEach(btn => {
|
|
btn.classList.toggle('selected', btn.dataset.mode === this.runMode);
|
|
});
|
|
// Load history sessions when menu opens
|
|
if (menu.classList.contains('active')) {
|
|
this._loadRunModeHistory();
|
|
this._refreshRunModeAvailability(menu);
|
|
this._refreshCustomModelRunOptions(menu);
|
|
const close = (ev) => {
|
|
if (!menu.contains(ev.target)) {
|
|
menu.classList.remove('active');
|
|
document.removeEventListener('click', close);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', close), 0);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* #201: hides run-mode dropdown entries for CLIs that aren't installed, so
|
|
* picking one doesn't spawn a session that immediately errors out.
|
|
*
|
|
* Shell has no external CLI dependency and is never gated, which is also what
|
|
* guarantees the menu is never empty. Scoped to `menu` rather than the document:
|
|
* `.run-mode-option` is also the class the saved-dashboard rows and the history
|
|
* rows use, and a bare querySelector would find whichever came first in the DOM.
|
|
*
|
|
* Antigravity and Pi are in this list even though #201 predates them — they are
|
|
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
|
*/
|
|
_refreshRunModeAvailability(menu) {
|
|
// A DOCKER case runs its agents INSIDE the container, so host CLI
|
|
// availability answers the wrong question: the host may have no claude at
|
|
// all while the container ships one, and gating on the host hides a mode
|
|
// that would have worked. Adoption records what the container really has
|
|
// (`availableModes`); an owned container runs our base image, which ships
|
|
// every CLI, so an absent list means "do not gate" rather than "nothing".
|
|
// Same source every run* path reads the selected case from.
|
|
const caseName = document.getElementById('quickStartCase')?.value;
|
|
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
|
const isDocker = activeCase?.location === 'docker';
|
|
// Prefer a LIVE probe over the value stored at attach time: a container's
|
|
// CLIs can be installed or removed long after the case was linked, and a
|
|
// case linked before that field existed has none at all.
|
|
const containerModes = isDocker
|
|
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
|
|
: null;
|
|
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
|
|
// An unreachable container hides every agent mode and explains why, instead
|
|
// of silently offering modes that cannot start.
|
|
//
|
|
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
|
|
// state before the first session — the launch chain creates and starts it — so
|
|
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
|
// behind "start it yourself first", for a container Codeman was about to create.
|
|
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
|
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
|
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
|
if (!btn) continue;
|
|
let available;
|
|
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
|
else available = this.isCliAvailable(mode);
|
|
btn.style.display = available ? 'flex' : 'none';
|
|
}
|
|
this._renderRunModeNotice(menu, probeError);
|
|
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
|
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
|
// ships no terminal front door. In that state the honest offer is "add one",
|
|
// not a hidden entry with no explanation anywhere.
|
|
const avail = window.__codemanCliAvailable || {};
|
|
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
|
|
if (dsInstall) {
|
|
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
|
|
}
|
|
// The web UI needs only the BINARY: it is the one interactive surface
|
|
// DeepSeek ships itself, so it works on a box with no terminal profile at
|
|
// all (and is the honest thing to offer there).
|
|
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
|
|
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
|
},
|
|
|
|
/**
|
|
* Generates the Run menu's Custom Model Endpoint entries
|
|
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
|
|
* endpoint) pair, e.g. "Claude Code (llama.cpp)". Hidden entirely when the
|
|
* feature is off, no endpoint has a usable default model, or the active case is
|
|
* remote/docker (the apply route refuses both — see session-routes.ts).
|
|
*
|
|
* `window.__codemanCustomModelClis` is server-injected at render time from the
|
|
* CLI registry's own `capabilities.customModelInjection` (never a hardcoded id
|
|
* list here), so a CLI gaining or losing the capability shows up with no
|
|
* frontend change.
|
|
*/
|
|
async _refreshCustomModelRunOptions(menu) {
|
|
const sep = menu.querySelector('#runModeCustomModelSep');
|
|
const header = menu.querySelector('#runModeCustomModelHeader');
|
|
const container = menu.querySelector('#runModeCustomModels');
|
|
if (!container) return;
|
|
const hide = () => {
|
|
if (sep) sep.style.display = 'none';
|
|
if (header) header.style.display = 'none';
|
|
container.innerHTML = '';
|
|
};
|
|
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
// Matches _refreshRunModeAvailability's own gate: a stock entry for an
|
|
// uninstalled CLI is hidden, so a generated one must be too, or a box with
|
|
// no codex still offers "Codex (llama.cpp)" and fails at launch.
|
|
const capableClis = (window.__codemanCustomModelClis || []).filter((cli) => this.isCliAvailable(cli.id));
|
|
if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide();
|
|
|
|
const caseName = document.getElementById('quickStartCase')?.value;
|
|
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
|
if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide();
|
|
|
|
// GET /api/model-endpoints wraps its body in the { success, data } envelope
|
|
// like every other /api route (server.ts's preSerialization hook applies to
|
|
// arrays too) — _apiJson() unwraps it. A raw fetch().json() here would
|
|
// silently see the envelope object instead of the array and hide this
|
|
// section unconditionally.
|
|
const hosts = await this._apiJson('/api/model-endpoints');
|
|
if (!Array.isArray(hosts) || hosts.length === 0) return hide();
|
|
|
|
const rows = [];
|
|
for (const host of hosts) {
|
|
const models = host.models || [];
|
|
if (models.length === 0) continue; // nothing discovered yet — the settings panel explains why
|
|
const modelId = host.defaultModelId || models[0];
|
|
for (const cli of capableClis) {
|
|
// escapeHtml(JSON.stringify(...)) on EVERY arg, not just the untrusted
|
|
// one: JSON.stringify's own double quotes would otherwise terminate this
|
|
// double-quoted attribute at the first one, and everything after parses
|
|
// as raw tag content rather than a quoted string — which is what turns
|
|
// modelId (server-controlled, from the endpoint's own /v1/models reply,
|
|
// not this box's) into markup instead of inert data. Same idiom as
|
|
// deleteCase's onclick a few hundred lines down.
|
|
const args = [cli.id, host.id].map((v) => escapeHtml(JSON.stringify(v))).join(', ');
|
|
rows.push(`
|
|
<button class="run-mode-option" data-mode="${escapeHtml(cli.id)}" data-endpoint="${escapeHtml(host.id)}"
|
|
onclick="app.selectCustomModelEntry(${args})"
|
|
title="${escapeHtml(cli.label)} → ${escapeHtml(host.baseUrl)} (${escapeHtml(modelId)}${models.length > 1 ? `, +${models.length - 1} more` : ''})">
|
|
<span class="run-mode-dot ${escapeHtml(cli.id)}"></span>${escapeHtml(cli.label)} (${escapeHtml(host.label)})
|
|
</button>`);
|
|
}
|
|
}
|
|
if (rows.length === 0) return hide();
|
|
if (sep) sep.style.display = '';
|
|
if (header) header.style.display = '';
|
|
container.innerHTML = rows.join('');
|
|
},
|
|
|
|
/**
|
|
* Decides whether picking a Run-menu Custom Endpoint entry can launch
|
|
* straight away or needs to ask which model first. Re-fetches the endpoint
|
|
* rather than trusting anything cached from the menu render: the models
|
|
* list (or the default) could have changed — a re-discovery cycle running
|
|
* every 5 minutes in the background, or an edit in the settings panel —
|
|
* between opening the dropdown and clicking a row.
|
|
*/
|
|
async selectCustomModelEntry(mode, endpointId) {
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
const hosts = await this._apiJson('/api/model-endpoints');
|
|
const host = (hosts || []).find((h) => h.id === endpointId);
|
|
if (!host) {
|
|
this.showToast('That endpoint no longer exists', 'error');
|
|
return;
|
|
}
|
|
const models = host.models || [];
|
|
if (models.length === 0) {
|
|
this.showToast('No models discovered for this endpoint yet', 'warning');
|
|
return;
|
|
}
|
|
// Exactly one model: nothing to choose, so asking would just be an extra
|
|
// click for the same answer every time. Two or more: always ask, even
|
|
// with a defaultModelId set — the point of asking is letting THIS launch
|
|
// differ from the default, not just confirming it.
|
|
if (models.length === 1) {
|
|
return this.runCustomModelEntry(mode, endpointId, models[0]);
|
|
}
|
|
this._openCustomModelPickModal(mode, host);
|
|
},
|
|
|
|
/** Renders the "which model" picker for a (harness, endpoint) pair with more than one discovered model. */
|
|
_openCustomModelPickModal(mode, host) {
|
|
const modal = document.getElementById('customModelPickModal');
|
|
const list = document.getElementById('customModelPickList');
|
|
if (!modal || !list) return;
|
|
this._pendingCustomModelPick = { mode, endpointId: host.id };
|
|
const cliLabel = (window.__codemanCustomModelClis || []).find((c) => c.id === mode)?.label || mode;
|
|
// A static title (translatable by i18n.js's exact-string walker) plus a
|
|
// dynamic hint carrying the specifics — same split webviewModalTitle uses,
|
|
// since the walker cannot i18n a string a variable is already spliced into.
|
|
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
|
document.getElementById('customModelPickHint').textContent =
|
|
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
|
list.innerHTML = (host.models || [])
|
|
.map((m) => {
|
|
const isDefault = m === host.defaultModelId;
|
|
const arg = escapeHtml(JSON.stringify(m));
|
|
return `
|
|
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
|
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${isDefault ? ' <span class="set-scope">Default</span>' : ''}
|
|
</button>`;
|
|
})
|
|
.join('');
|
|
modal.classList.add('active');
|
|
},
|
|
|
|
closeCustomModelPickModal() {
|
|
document.getElementById('customModelPickModal')?.classList.remove('active');
|
|
this._pendingCustomModelPick = null;
|
|
},
|
|
|
|
/**
|
|
* In-app replacement for a native `confirm()` popup, used specifically for the
|
|
* llama-swap "this will unload it for session X" warning (both launch paths below) —
|
|
* a browser-chrome dialog there looked out of place next to the rest of the app's own
|
|
* modals. Resolves true/false the same way `confirm()` would; `_resolveModelSwapConfirm`
|
|
* (the modal's own Cancel/Switch-anyway buttons, and its backdrop click) is what settles
|
|
* the returned promise.
|
|
*/
|
|
_confirmModelSwap(message) {
|
|
const modal = document.getElementById('customModelSwapConfirmModal');
|
|
const messageEl = document.getElementById('customModelSwapConfirmMessage');
|
|
if (messageEl) messageEl.textContent = message;
|
|
modal?.classList.add('active');
|
|
return new Promise((resolve) => {
|
|
this._resolveModelSwapConfirmPromise = resolve;
|
|
});
|
|
},
|
|
|
|
/** Called by the modal's Cancel/Switch-anyway buttons and its backdrop click. */
|
|
_resolveModelSwapConfirm(proceed) {
|
|
document.getElementById('customModelSwapConfirmModal')?.classList.remove('active');
|
|
const resolve = this._resolveModelSwapConfirmPromise;
|
|
this._resolveModelSwapConfirmPromise = null;
|
|
resolve?.(proceed);
|
|
},
|
|
|
|
/**
|
|
* In-app warning shown when the apply route reports `requiresContextWarning`: this
|
|
* model's real discovered context is smaller than the CLI's own fixed system-prompt/
|
|
* tool-schema overhead, which guarantees the very first message fails outright — no
|
|
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes that, since there is no conversation
|
|
* history yet for compaction to trim. Same promise-based pattern as
|
|
* `_confirmModelSwap`; `_resolveContextWarningConfirm` settles it.
|
|
*/
|
|
_confirmContextWarning(modelId, contextLength, minSafeContextTokens) {
|
|
const modal = document.getElementById('customModelContextWarningModal');
|
|
const messageEl = document.getElementById('customModelContextWarningMessage');
|
|
if (messageEl) {
|
|
const known = typeof contextLength === 'number';
|
|
messageEl.textContent =
|
|
`${modelId} is configured with ` +
|
|
(known ? `only ${contextLength.toLocaleString()} tokens of` : 'an unknown (too small)') +
|
|
` context, but this CLI needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own ` +
|
|
`system prompt and tools — before any conversation history. Its very first message will fail outright, ` +
|
|
`no matter what context size Codeman tells it to expect.\n\n` +
|
|
`To fix this, reconfigure llama-swap to give this model (or a smaller one) an explicit larger context ` +
|
|
`instead of relying on auto-fit (--fit-ctx), which optimizes for the biggest MODEL that fits, not the ` +
|
|
`biggest CONTEXT — e.g. add "-c 65536" (or as large a --ctx-size as your hardware holds) to its llama-swap ` +
|
|
`config entry. A smaller model at a much larger explicit context often fits in the same VRAM a bigger ` +
|
|
`model's auto-fit context gets shrunk to make room for.`;
|
|
}
|
|
modal?.classList.add('active');
|
|
return new Promise((resolve) => {
|
|
this._resolveContextWarningConfirmPromise = resolve;
|
|
});
|
|
},
|
|
|
|
/** Called by the modal's Cancel/Launch-anyway buttons and its backdrop click. */
|
|
_resolveContextWarningConfirm(proceed) {
|
|
document.getElementById('customModelContextWarningModal')?.classList.remove('active');
|
|
const resolve = this._resolveContextWarningConfirmPromise;
|
|
this._resolveContextWarningConfirmPromise = null;
|
|
resolve?.(proceed);
|
|
},
|
|
|
|
/** A model row in the picker modal was clicked: close it and launch with that choice. */
|
|
chooseCustomModelAndRun(modelId) {
|
|
const pending = this._pendingCustomModelPick;
|
|
this.closeCustomModelPickModal();
|
|
if (!pending) return; // modal reopened/closed from elsewhere between render and click
|
|
void this.runCustomModelEntry(pending.mode, pending.endpointId, modelId);
|
|
},
|
|
|
|
/**
|
|
* Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it
|
|
* via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same
|
|
* restart-in-place apply path the (not-yet-built) endpoint-management surface
|
|
* would use for an already-running session. A custom-model run is a one-off
|
|
* "try this endpoint" action, not a sticky mode.
|
|
*
|
|
* Routes through run() itself, via a temporary `_runMode` swap, rather than a
|
|
* parallel dispatch table: that is what gives this the same in-flight lock
|
|
* every other Run click gets (CLAUDE.md, Run launch synchronization — the lock
|
|
* exists so a double click cannot create duplicate sessions with the same
|
|
* `w<n>-<case>` name, and it guards the OTHER direction too: without it, the
|
|
* main Run button could start a second concurrent launch while this one was
|
|
* still resolving), and it means a CLI whose customModelInjection recipe
|
|
* lands later needs no update here, only in run()'s own dispatch. The swap
|
|
* never persists — setRunMode() would sync it to the server as the user's new
|
|
* default, which a one-off endpoint run must not do — and is restored in
|
|
* `finally` even if run() throws.
|
|
*/
|
|
/**
|
|
* Dispatches to the ONE-SHOT launch path (below) for every custom-model-eligible CLI
|
|
* except claude, which still goes through the restart-after-native-boot path
|
|
* (`_runCustomModelEntryViaRestart`): claude's own `runClaude()` carries multi-tab
|
|
* launch and a docker-config-drift confirm/retry loop neither of the other seven
|
|
* functions has, and folding those into the one-shot flow is unstarted, separate work.
|
|
* The other seven (opencode/codex/gemini/pi/grok/deepseek/omp) are each a single,
|
|
* simple launch, so they get the one-shot path — the one visibly worth it, since a
|
|
* native-boot-then-restart is far more jarring on a CLI whose TUI fully reinitializes
|
|
* (Codex, confirmed live) than on claude's own `--resume`-based restart.
|
|
*/
|
|
async runCustomModelEntry(mode, endpointId, modelId) {
|
|
if (mode === 'claude') {
|
|
return this._runCustomModelEntryViaRestart(mode, endpointId, modelId);
|
|
}
|
|
return this._runCustomModelEntryOneShot(mode, endpointId, modelId);
|
|
},
|
|
|
|
/**
|
|
* Launches directly on the endpoint — no restart, so no visible relaunch. Stashes the
|
|
* pick on `_pendingCustomModelForLaunch` for the targeted run<Mode>() function to read
|
|
* and fold into its own /api/quick-start body (see `_quickStartWithCustomModelConfirm`);
|
|
* cleared in `finally` the same way `_runMode`'s temporary swap is, even if run() throws.
|
|
*/
|
|
async _runCustomModelEntryOneShot(mode, endpointId, modelId) {
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
const previousRunMode = this._runMode;
|
|
const tabCountEl = document.getElementById('tabCount');
|
|
const prevTabCount = tabCountEl?.value;
|
|
this._runMode = mode;
|
|
this._pendingCustomModelForLaunch = { endpointId, modelId };
|
|
if (tabCountEl) tabCountEl.value = '1';
|
|
try {
|
|
await this.run();
|
|
} finally {
|
|
this._runMode = previousRunMode;
|
|
this._pendingCustomModelForLaunch = undefined;
|
|
if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount;
|
|
}
|
|
|
|
// run() (via _quickStartWithCustomModelConfirm) reports its own launch error or
|
|
// cancellation via toast and leaves this unset — nothing more to do here then.
|
|
const result = this._lastCustomModelLaunchResult;
|
|
this._lastCustomModelLaunchResult = undefined;
|
|
if (result?.modelSwapInProgress) {
|
|
void this._watchLlamaSwapLoading(endpointId, modelId, result.sessionId);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* POSTs a /api/quick-start body already carrying `customModel` (see the run<Mode>()
|
|
* call sites below), showing the same llama-swap "this will unload it for session X"
|
|
* warning the restart path's `_applyCustomModelToSession` shows when the route asks
|
|
* for confirmation, and retrying with `confirmed: true` on accept. Stashes the final
|
|
* response's payload on `_lastCustomModelLaunchResult` for
|
|
* `_runCustomModelEntryOneShot` to read `modelSwapInProgress` off afterward — run()'s
|
|
* eleven per-mode dispatch targets have no shared return-value contract of their own,
|
|
* so a side channel here is simpler than threading one through every one of them.
|
|
*/
|
|
async _quickStartWithCustomModelConfirm(bodyObj) {
|
|
const post = async (body) => {
|
|
const res = await fetch('/api/quick-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res.json();
|
|
};
|
|
let data = await post(bodyObj);
|
|
if (data?.data?.requiresContextWarning) {
|
|
const { modelId, contextLength, minSafeContextTokens } = data.data;
|
|
const proceed = await this._confirmContextWarning(modelId, contextLength, minSafeContextTokens);
|
|
if (!proceed) {
|
|
this._lastCustomModelLaunchResult = undefined;
|
|
return { success: false, error: 'Launch cancelled — context window too small' };
|
|
}
|
|
data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, confirmed: true } });
|
|
}
|
|
if (data?.data?.requiresConfirmation) {
|
|
const { currentlyLoadedModel, affectedSessions } = data.data;
|
|
const names = affectedSessions.map((s) => s.name || s.id).join(', ');
|
|
const proceed = await this._confirmModelSwap(
|
|
`${names} ${affectedSessions.length === 1 ? 'is' : 'are'} currently using ` +
|
|
`${currentlyLoadedModel} on this endpoint. Switching will unload it for ` +
|
|
`${affectedSessions.length === 1 ? 'that session' : 'those sessions'} too. Continue?`
|
|
);
|
|
if (!proceed) {
|
|
this._lastCustomModelLaunchResult = undefined;
|
|
return { success: false, error: 'Model switch cancelled' };
|
|
}
|
|
data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, confirmed: true } });
|
|
}
|
|
this._lastCustomModelLaunchResult = data?.success !== false ? data?.data : undefined;
|
|
return data;
|
|
},
|
|
|
|
/** The restart-after-native-boot path — see `runCustomModelEntry`'s own comment for
|
|
* which CLIs still use this one. */
|
|
async _runCustomModelEntryViaRestart(mode, endpointId, modelId) {
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
|
|
const previousRunMode = this._runMode;
|
|
const before = this.activeSessionId;
|
|
const tabCountEl = document.getElementById('tabCount');
|
|
const prevTabCount = tabCountEl?.value;
|
|
this._runMode = mode;
|
|
if (tabCountEl) tabCountEl.value = '1';
|
|
try {
|
|
await this.run();
|
|
} finally {
|
|
this._runMode = previousRunMode;
|
|
if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount;
|
|
}
|
|
|
|
// run() reports its own errors via toast. Every run*() function handles its
|
|
// own failure internally and returns normally rather than throwing or
|
|
// leaving activeSessionId null, so a declined/failed launch (missing CLI, a
|
|
// caught exception, isBusy on the session the launch would have targeted)
|
|
// falls through to here with the PREVIOUSLY active session still active.
|
|
// Requiring the id to have actually changed — not just to be non-null — is
|
|
// what stops that case from silently re-pointing and restarting whatever
|
|
// session the user was already looking at.
|
|
const sessionId = this.activeSessionId;
|
|
if (!sessionId || sessionId === before) return;
|
|
|
|
// Claude just launched on the NATIVE backend and is about to be restarted onto
|
|
// the endpoint — without something saying so, that native boot (which can talk
|
|
// to Opus for a moment) reads as "the endpoint didn't apply" rather than "the
|
|
// switch hasn't happened yet". Prominent and screen-centred (not a corner toast)
|
|
// since this can sit on screen for a while; sticky until the apply below settles
|
|
// one way or the other, or hands off to _watchLlamaSwapLoading's own banner.
|
|
const switchingToast = this._showCenterStatus(`Claude started — switching to ${endpointId}…`);
|
|
|
|
// A freshly launched CLI reports its OWN startup as 'busy' (spinner, the
|
|
// workspace-trust check, whatever else it does before its first prompt) —
|
|
// measured landing well before this line reliably reaches it — and the
|
|
// apply route's isBusy() guard correctly refuses to restart a session
|
|
// mid-turn, "mid-turn" included, which this fresh boot looks exactly
|
|
// like from the outside. Give it a bounded chance to settle first rather
|
|
// than raising a false "Session is busy" on every single launch. Per the
|
|
// wait contract a timeout here is a normal 200, never an error — a
|
|
// session still busy after 20s just reaches the apply call below and
|
|
// gets the route's own honest, now-visible SESSION_BUSY error instead of
|
|
// this guessing about it.
|
|
await this._apiJson(`/api/sessions/${sessionId}/wait?until=idle&timeout=20000`);
|
|
|
|
// _apiJson() (used everywhere else in this file) unwraps a success body to
|
|
// its `data`, but on failure it swallows the response entirely and returns
|
|
// null — exactly the `error` text a caller needs to tell "the endpoint is
|
|
// unreachable" apart from "the CLI can't be redirected", "not one of the
|
|
// discovered models", or "this is a Docker/remote session". Go through the
|
|
// raw response here instead so a failure is diagnosable, not just present.
|
|
let { ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId);
|
|
|
|
// A success body comes back as {success:true, data:{...}} (server.ts's preSerialization
|
|
// envelope), but a route-level error is {success:false, error, errorCode} with no nested
|
|
// data — createErrorResponse() never wraps one. `payload` below is only ever meaningful
|
|
// once `data.success !== false`.
|
|
let payload = data?.success !== false ? data?.data : undefined;
|
|
|
|
// This CLI's own fixed overhead (system prompt + tool schemas) may exceed the
|
|
// model's real discovered context outright — no context-length declaration can
|
|
// fix that, since compaction only trims conversation history and there is none
|
|
// on message 1. Warn and let the user decide whether to launch anyway, same
|
|
// confirmed:true re-send pattern as the swap check below.
|
|
if (ok && payload?.requiresContextWarning) {
|
|
const proceed = await this._confirmContextWarning(
|
|
payload.modelId,
|
|
payload.contextLength,
|
|
payload.minSafeContextTokens
|
|
);
|
|
if (!proceed) {
|
|
switchingToast?.dismiss();
|
|
this.showToast('Kept the native backend — context window too small', 'info');
|
|
return;
|
|
}
|
|
({ ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId, true));
|
|
payload = data?.success !== false ? data?.data : undefined;
|
|
}
|
|
|
|
// llama-swap runs one model at a time: switching would unload it out from under
|
|
// another session actively using it. The route only asks when that's actually true
|
|
// (never just because a swap is needed at all) — confirming re-sends the exact same
|
|
// call with `confirmed: true` so the route skips the check the second time.
|
|
if (ok && payload?.requiresConfirmation) {
|
|
const names = payload.affectedSessions.map((s) => s.name || s.id).join(', ');
|
|
const proceed = await this._confirmModelSwap(
|
|
`${names} ${payload.affectedSessions.length === 1 ? 'is' : 'are'} currently using ` +
|
|
`${payload.currentlyLoadedModel} on this endpoint. Switching to ${modelId} will unload it ` +
|
|
`for ${payload.affectedSessions.length === 1 ? 'that session' : 'those sessions'} too. Continue?`
|
|
);
|
|
if (!proceed) {
|
|
switchingToast?.dismiss();
|
|
this.showToast('Kept the native backend — model switch cancelled', 'info');
|
|
return;
|
|
}
|
|
({ ok, data, res } = await this._applyCustomModelToSession(sessionId, endpointId, modelId, true));
|
|
payload = data?.success !== false ? data?.data : undefined;
|
|
}
|
|
|
|
if (!ok || !data || data.success === false) {
|
|
switchingToast?.dismiss();
|
|
const detail = data?.error ? `: ${data.error}` : res ? ` (HTTP ${res.status})` : ' (request failed)';
|
|
this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// The apply above already succeeded — the session IS pointed at the endpoint — but
|
|
// llama-swap itself may still be unloading the old model and loading this one, which
|
|
// can take well over a minute. Without this, a prompt sent during that window either
|
|
// hangs silently or (the bug this whole feature exists to fix) gets answered by
|
|
// whatever was loaded a moment ago, reading as "it's still using the wrong model."
|
|
// Hand off to its own sticky toast rather than stacking a second one on top.
|
|
if (payload?.modelSwapInProgress) {
|
|
switchingToast?.dismiss();
|
|
void this._watchLlamaSwapLoading(endpointId, modelId, sessionId);
|
|
return;
|
|
}
|
|
|
|
switchingToast?.setMessage(`Pointed at ${endpointId} — restarting the session...`);
|
|
setTimeout(() => switchingToast?.dismiss(), 3000);
|
|
},
|
|
|
|
/** POST /api/sessions/:id/custom-model, returning {ok, data, res} rather than throwing —
|
|
* see runCustomModelEntry's own comment for why this goes through `_api()` (raw fetch)
|
|
* rather than `_apiJson()`: a failure's `error` detail must survive to the caller. */
|
|
async _applyCustomModelToSession(sessionId, endpointId, modelId, confirmed) {
|
|
const res = await this._api(`/api/sessions/${sessionId}/custom-model`, {
|
|
method: 'POST',
|
|
body: confirmed ? { endpointId, modelId, confirmed } : { endpointId, modelId },
|
|
});
|
|
const data = res ? await res.json().catch(() => null) : null;
|
|
return { ok: !!res, data, res };
|
|
},
|
|
|
|
/**
|
|
* Best-effort: looks up `modelId`'s discovered file size (GB) off the endpoint's own
|
|
* saved host record (`CustomModelHost.modelSizesGB`, populated during discovery by
|
|
* parsing llama-swap's own `description` field for an auto-discovered model). Returns
|
|
* `undefined` for a hand-configured profile with no parseable size, an unreachable
|
|
* server, or any other failure — never a guess.
|
|
*/
|
|
async _lookupModelSizeGB(endpointId, modelId) {
|
|
const hosts = await this._apiJson('/api/model-endpoints').catch(() => null);
|
|
if (!Array.isArray(hosts)) return undefined;
|
|
const host = hosts.find((h) => h.id === endpointId);
|
|
const size = host?.modelSizesGB?.[modelId];
|
|
return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : undefined;
|
|
},
|
|
|
|
/**
|
|
* Strips llama.cpp's own bootlog prefix (`<uptime> <I|W|E> <component> `, e.g.
|
|
* `0.31.428.568 I srv llama_server: model loaded`) for display, leaving just
|
|
* `llama_server: model loaded` — the raw line from the server is kept as-is
|
|
* (`GET .../running-status`'s `logLine` field), this trims it only for the loading
|
|
* banner's second line. Defensive: a line that doesn't match this shape (a different
|
|
* llama.cpp build, or llama-swap's own format changing) is shown verbatim rather than
|
|
* mangled or dropped.
|
|
*/
|
|
_formatLlamaLogLine(line) {
|
|
return typeof line === 'string' ? line.replace(/^[\d.]+\s+[IWE]\s+\S+\s+/, '') : line;
|
|
},
|
|
|
|
/**
|
|
* Polls llama-swap's own `/running` (via the read-only running-status route) until
|
|
* `modelId` reports `state: 'ready'`, showing a sticky banner the whole time so a slow
|
|
* unload/reload (measured well over a minute for a large model) reads as "loading,
|
|
* still working on it", never as silence or a wrong answer from whatever was loaded
|
|
* before. Checks immediately (a fast load, or a re-apply onto an already-ready model,
|
|
* shouldn't wait a full interval to say so), then every `pollIntervalMs`.
|
|
*
|
|
* Deliberately UNBOUNDED — no estimate, no countdown, no automatic give-up. An earlier
|
|
* version scaled a timeout off the model's discovered file size and auto-closed the
|
|
* session when it elapsed, but a real load's actual duration depends on hardware this
|
|
* feature has no way to know (VRAM, storage speed, what else is contending for the
|
|
* GPU), so any fixed number was a guess dressed up as a fact — the banner now says so
|
|
* outright instead of pretending to a precision it doesn't have, and a Cancel button on
|
|
* the banner itself (`_showCenterStatus`'s `onCancel`) is how the user ends it if it's
|
|
* taking too long, closing `sessionId` the same way the old timeout used to.
|
|
*
|
|
* `_watchLlamaSwapGeneration` guards against two overlapping calls (a second launch
|
|
* started before the first one's loop finished) clobbering each other's banner:
|
|
* `_showCenterStatus` reuses one shared DOM node, so an older loop's `dismiss()`/message
|
|
* update firing after a newer one has already taken over the banner would otherwise hide
|
|
* or overwrite the WRONG one, or close the WRONG session. Each call claims the counter
|
|
* as its own "generation" and checks it still owns it before touching either.
|
|
*
|
|
* `pollIntervalMs` exists to let a test drive this in milliseconds instead of seconds —
|
|
* real callers never pass it.
|
|
*/
|
|
async _watchLlamaSwapLoading(endpointId, modelId, sessionId, pollIntervalMs = 1000) {
|
|
const generation = (this._watchLlamaSwapGeneration = (this._watchLlamaSwapGeneration || 0) + 1);
|
|
const isCurrent = () => this._watchLlamaSwapGeneration === generation;
|
|
const sizeGB = await this._lookupModelSizeGB(endpointId, modelId);
|
|
if (!isCurrent()) return; // a newer launch already took over before the lookup even finished
|
|
const sizeSuffix = sizeGB ? ` (${sizeGB.toFixed(1)} GB)` : '';
|
|
const baseMessage =
|
|
`Loading ${modelId}${sizeSuffix} on ${endpointId} — this can take a while depending on ` +
|
|
`your hardware and the model size.`;
|
|
// Second line, when llama-swap's own event feed actually gives us one: the real
|
|
// backend llama-server process's own latest log line (load_model:/llama_server: ...,
|
|
// see getLatestLlamaSwapLogLine) — a bare "please wait" says nothing is broken, this
|
|
// says what's actually happening. Absent on the very first render (no poll has
|
|
// landed yet) and whenever the endpoint doesn't expose it at all — never fabricated,
|
|
// and never cleared back to blank once seen (stays on the last real thing llama.cpp
|
|
// said if a later poll comes back with nothing new).
|
|
const buildMessage = (logLine) => {
|
|
const line = this._formatLlamaLogLine(logLine);
|
|
return baseMessage + (line ? `\nllama.cpp: ${line}` : '');
|
|
};
|
|
let cancelled = false;
|
|
// Prominent and screen-centred, not a corner toast — a real llama-swap model load can
|
|
// sit on screen for well over a minute, easy to mistake for nothing happening there.
|
|
const toast = this._showCenterStatus(buildMessage(), {
|
|
onCancel: () => {
|
|
cancelled = true;
|
|
},
|
|
});
|
|
while (!cancelled) {
|
|
const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`);
|
|
if (!isCurrent()) return; // a newer launch took over the banner — this loop is done
|
|
if (cancelled) break;
|
|
if (!status) {
|
|
// transient failure — keep waiting rather than giving up early
|
|
} else if (!status.isLlamaSwap) {
|
|
// Endpoint changed under us, or wasn't llama-swap after all — nothing more to
|
|
// watch for, and not a failure worth a toast of its own.
|
|
toast?.dismiss();
|
|
return;
|
|
} else if (status.running.some((r) => r.model === modelId && r.state === 'ready')) {
|
|
toast?.dismiss();
|
|
this.showToast(`${modelId} is ready`, 'success', { duration: 2500 });
|
|
return;
|
|
}
|
|
if (!isCurrent() || cancelled) break;
|
|
toast?.setMessage(buildMessage(status?.logLine));
|
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
}
|
|
if (!isCurrent()) return;
|
|
// Cancelled by the user, not a timeout — an ordinary info toast, not a scary error
|
|
// banner, since this was deliberate rather than something going wrong.
|
|
toast?.dismiss();
|
|
this.showToast(
|
|
`Cancelled loading ${modelId} on ${endpointId}` + (sessionId ? ' — the session has been closed.' : '.'),
|
|
'info'
|
|
);
|
|
if (sessionId) {
|
|
try {
|
|
await this.closeSession(sessionId);
|
|
} catch {
|
|
// closeSession already reports its own failure via toast — nothing more to do here
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
|
|
*
|
|
* The server is a background child process owned by
|
|
* `deepseek-web-server.ts`, NOT a shell session. It was a shell session first,
|
|
* on the reasoning that Codeman already supervises those, and that version
|
|
* worked - it just put a terminal tab on screen beside the web tab the user
|
|
* actually asked for, on every click. Opening a dashboard should open one tab.
|
|
*
|
|
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
|
|
* browser-trust check on the request authority, and a Codeman web tab reaches
|
|
* it through Codeman's own origin via the webview proxy, not directly. Without
|
|
* passing Codeman's authority the page renders and every API call fails.
|
|
*
|
|
* The tab is saved `trusted: true`, and that is REQUIRED rather than a
|
|
* convenience: an untrusted webview is sandboxed without `allow-same-origin`,
|
|
* which breaks this dashboard twice over. The dsh client-runtime reads
|
|
* `localStorage` while loading its plugins and dies there ("the document is
|
|
* sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin
|
|
* frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call
|
|
* no matter which authority `--trusted-host` names. Passing `location.host`
|
|
* only means anything once the frame actually carries that origin.
|
|
*
|
|
* The trade this makes is real and worth stating: a trusted proxied frame is
|
|
* same-origin with Codeman and can therefore reach Codeman's own API. It is
|
|
* defensible only because of what this specific dashboard already is - an
|
|
* agent harness Codeman just started itself, on loopback, which can run code
|
|
* as the user regardless. It is not a precedent for trusting third-party
|
|
* dashboards generally, which is why it is set here rather than defaulted.
|
|
*/
|
|
async runDeepSeekWeb() {
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...');
|
|
|
|
try {
|
|
// One request, and the server owns everything behind it: picking a free
|
|
// port, spawning, waiting for the port to answer, and reusing an already
|
|
// running server instead of racing it. This used to start the server in a
|
|
// shell SESSION, which worked but put a terminal tab on screen next to the
|
|
// web tab actually asked for, every single time.
|
|
//
|
|
// `authority` is what dsh fences its own `/api` behind (`--trusted-host`),
|
|
// so it must be the origin this page is loaded from rather than anything
|
|
// the server could guess: a Codeman reachable at both loopback and a
|
|
// tailnet name has two, and only the browser knows which one is in play.
|
|
const startRes = await fetch('/api/deepseek/web', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ authority: location.host }),
|
|
});
|
|
const startData = await startRes.json();
|
|
if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI');
|
|
const url = startData.data.url;
|
|
|
|
// One managed record, repointed rather than duplicated: the port is chosen
|
|
// per launch, so creating a fresh row each time would stack a dashboard
|
|
// per restart, each pointing at a port nothing serves any more.
|
|
let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
|
|
if (webview) {
|
|
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, trusted: true }),
|
|
});
|
|
const patchData = await patchRes.json();
|
|
if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab');
|
|
webview = patchData.data.webview || patchData.data;
|
|
} else {
|
|
const wvRes = await fetch('/api/webviews', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: 'DeepSeek Harness',
|
|
url,
|
|
icon: '\u{1F433}',
|
|
managed: 'deepseek-web',
|
|
trusted: true,
|
|
}),
|
|
});
|
|
const wvData = await wvRes.json();
|
|
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
|
webview = wvData.data.webview || wvData.data;
|
|
}
|
|
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
|
|
// this.webviews and silently no-ops on an id it has not loaded, so
|
|
// skipping the refresh made the FIRST click create the record but open
|
|
// nothing (the SSE round-trip had not landed yet).
|
|
await this.refreshWebviews?.();
|
|
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
|
|
if (webview?.id) await this.openWebview(webview.id);
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Install a DeepSeek Harness terminal profile from the run menu.
|
|
*
|
|
* Held open for as long as the package manager takes (the endpoint bounds it),
|
|
* so the button reports progress rather than appearing to do nothing. On
|
|
* success the availability map is patched in place, which is what makes the
|
|
* real DeepSeek entry appear without a reload.
|
|
*/
|
|
async installDeepSeekProfile() {
|
|
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
|
|
try {
|
|
const res = await fetch('/api/deepseek/install-profile', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
|
|
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
|
|
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
|
|
const menu = document.getElementById('runModeMenu');
|
|
if (menu) this._refreshRunModeAvailability(menu);
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* One-line explanation at the top of the run menu. Only a container that could
|
|
* not be read produces one; everything else removes it, so a stale reason can
|
|
* never outlive the condition that caused it.
|
|
*/
|
|
_renderRunModeNotice(menu, message) {
|
|
if (!menu) return;
|
|
let el = menu.querySelector('.run-mode-notice');
|
|
if (!message) {
|
|
el?.remove();
|
|
return;
|
|
}
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.className = 'run-mode-notice';
|
|
menu.prepend(el);
|
|
}
|
|
// Server-supplied text: set it, never parse it as markup.
|
|
el.textContent = message;
|
|
},
|
|
|
|
/**
|
|
* Ask the container which CLIs it actually has, and re-gate the menu once the
|
|
* answer lands. Cached per case for the page's lifetime: the menu re-opens
|
|
* often and the probe is a `docker exec` round trip.
|
|
*
|
|
* Best-effort by design — an unreachable daemon or a stopped container leaves
|
|
* the cache empty, which the caller reads as "unknown" and therefore does not
|
|
* gate. Hiding every mode because a probe failed would be worse than showing
|
|
* one that turns out to be missing, which the launch path already refuses with
|
|
* a specific message.
|
|
*/
|
|
async _probeDockerCaseModes(activeCase, menu) {
|
|
const name = activeCase?.name;
|
|
const container = activeCase?.docker?.container;
|
|
const hostId = activeCase?.docker?.hostId;
|
|
if (!name || !container || !hostId) return;
|
|
this._dockerCaseModes = this._dockerCaseModes || {};
|
|
if (this._dockerModeProbeInFlight?.[name]) return;
|
|
this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {};
|
|
this._dockerModeProbeInFlight[name] = true;
|
|
try {
|
|
// ⚠️ _api serializes `body` and sets Content-Type itself. Passing an
|
|
// already-stringified body double-encodes it and the server rejects a
|
|
// JSON string where it expects an object (400 INVALID_INPUT).
|
|
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
|
method: 'POST',
|
|
body: { hostId, container },
|
|
});
|
|
if (probe?.ok && Array.isArray(probe.availableModes)) {
|
|
this._dockerCaseModes[name] = probe.availableModes;
|
|
delete this._dockerCaseProbeError?.[name];
|
|
} else {
|
|
// An ADOPTED container that cannot be probed — recreated, stopped, engine
|
|
// down — must NOT fall through to "show everything". Offering claude on a
|
|
// container that is not running is a click that can only fail, with the
|
|
// reason visible nowhere. Record the reason and say it in the menu.
|
|
//
|
|
// ⚠️ An OWNED container gets no error: it does not exist until the first
|
|
// session launches it, so "not found" is the expected answer for every
|
|
// newly linked Docker case, and gating on it made those cases unusable.
|
|
// Leaving the cache empty reads as "unknown", which does not gate.
|
|
if (activeCase?.docker?.owned === false) {
|
|
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
|
|
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
|
|
}
|
|
delete this._dockerCaseModes[name];
|
|
}
|
|
// Only repaint while the menu the user opened is still on screen.
|
|
if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu);
|
|
} finally {
|
|
delete this._dockerModeProbeInFlight[name];
|
|
}
|
|
},
|
|
|
|
async _loadRunModeHistory() {
|
|
const container = document.getElementById('runModeHistory');
|
|
if (!container) return;
|
|
container.innerHTML = '<div class="run-mode-hist-empty">Loading...</div>';
|
|
|
|
try {
|
|
const display = await this._fetchHistorySessions(10);
|
|
if (display.length === 0) {
|
|
container.innerHTML = '<div class="run-mode-hist-empty">No history</div>';
|
|
return;
|
|
}
|
|
|
|
// Build items using DOM API for reliable mobile touch handling
|
|
container.replaceChildren();
|
|
for (const s of display) {
|
|
const date = new Date(s.lastModified);
|
|
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
|
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
// Shared helper, not a local regex: the copy that used to live here
|
|
// matched `/home/<user>/` only, so on macOS (`/Users/<user>/`) nothing was
|
|
// stripped and every row spent its first ~19 characters on an identical
|
|
// prefix — with the tail ellipsized, all rows rendered as
|
|
// `/Users/jordanryan/co…` and became indistinguishable (#273).
|
|
const shortDir = this._shortenHomePath(s.workingDir);
|
|
// Lead with the folder that identifies the row; the parent path trails and
|
|
// is what gets truncated. Truncation must never eat the identity.
|
|
const lastSlash = shortDir.lastIndexOf('/');
|
|
const leafName = lastSlash === -1 ? shortDir : shortDir.slice(lastSlash + 1);
|
|
// `<repo>/.claude/worktrees` in the parent path is pure noise once the pill
|
|
// says which worktree it is — drop it so the repo stays visible instead.
|
|
const parentDir = (lastSlash === -1 ? '' : shortDir.slice(0, lastSlash)).replace(/\/\.claude\/worktrees$/, '');
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'run-mode-option run-mode-hist-row';
|
|
btn.title = s.workingDir;
|
|
btn.dataset.sessionId = s.sessionId;
|
|
btn.dataset.workingDir = s.workingDir;
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'hist-name';
|
|
nameSpan.textContent = leafName;
|
|
|
|
const parts = [nameSpan];
|
|
|
|
// Worktree pill, same data the session rows use (#266). A worktree's
|
|
// directory basename is often just the worktree name, so without this two
|
|
// worktrees of one repo still read alike.
|
|
const wt = this._worktreeLabel ? this._worktreeLabel(s) : '';
|
|
if (wt) {
|
|
const wtSpan = document.createElement('span');
|
|
wtSpan.className = 'hist-wt';
|
|
wtSpan.textContent = wt;
|
|
parts.push(wtSpan);
|
|
}
|
|
|
|
if (parentDir) {
|
|
const dirSpan = document.createElement('span');
|
|
dirSpan.className = 'hist-dir';
|
|
dirSpan.textContent = parentDir;
|
|
parts.push(dirSpan);
|
|
}
|
|
|
|
const metaSpan = document.createElement('span');
|
|
metaSpan.className = 'hist-meta';
|
|
metaSpan.textContent = timeStr;
|
|
parts.push(metaSpan);
|
|
|
|
btn.append(...parts);
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId);
|
|
});
|
|
container.appendChild(btn);
|
|
}
|
|
} catch (err) {
|
|
container.innerHTML = '<div class="run-mode-hist-empty">Failed to load</div>';
|
|
}
|
|
},
|
|
|
|
_applyRunMode() {
|
|
const mode = this.runMode;
|
|
const runBtn = document.getElementById('runBtn');
|
|
const gearBtn = runBtn?.nextElementSibling;
|
|
const label = document.getElementById('runBtnLabel');
|
|
if (runBtn) {
|
|
runBtn.className = `btn-toolbar btn-run mode-${mode}`;
|
|
}
|
|
if (gearBtn) {
|
|
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' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : 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();
|
|
},
|
|
|
|
// Tab count stepper functions
|
|
incrementTabCount() {
|
|
const input = document.getElementById('tabCount');
|
|
const current = parseInt(input.value) || 1;
|
|
input.value = Math.min(20, current + 1);
|
|
},
|
|
|
|
decrementTabCount() {
|
|
const input = document.getElementById('tabCount');
|
|
const current = parseInt(input.value) || 1;
|
|
input.value = Math.max(1, current - 1);
|
|
},
|
|
|
|
// Shell count stepper functions
|
|
incrementShellCount() {
|
|
const input = document.getElementById('shellCount');
|
|
const current = parseInt(input.value) || 1;
|
|
input.value = Math.min(20, current + 1);
|
|
},
|
|
|
|
decrementShellCount() {
|
|
const input = document.getElementById('shellCount');
|
|
const current = parseInt(input.value) || 1;
|
|
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;
|
|
},
|
|
|
|
/**
|
|
* Launch progress may use the terminal only on the session-less home screen.
|
|
* When another session is active, mutating the shared xterm would serialize
|
|
* launch chrome into that session's snapshot during the subsequent switch.
|
|
*/
|
|
_beginSessionLaunchStatus(message, ansiColor = '1;32') {
|
|
const ownsTerminal = !this.activeSessionId;
|
|
if (ownsTerminal) {
|
|
this.terminal.clear();
|
|
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
|
this.terminal.writeln('');
|
|
} else {
|
|
this.showToast?.(message, 'info');
|
|
}
|
|
return ownsTerminal;
|
|
},
|
|
|
|
_appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') {
|
|
if (!ownsTerminal || this.activeSessionId) return;
|
|
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
|
},
|
|
|
|
_reportSessionLaunchError(ownsTerminal, message) {
|
|
if (ownsTerminal && !this.activeSessionId) {
|
|
this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`);
|
|
} else {
|
|
this.showToast?.(message, 'error');
|
|
}
|
|
},
|
|
|
|
async runClaude() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
|
);
|
|
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
|
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
|
// before the first async call. The keyboard opens here and stays open through
|
|
// the session creation flow; selectSession at the end inherits the focus state.
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
// Get case path first
|
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
|
let caseData = (await caseRes.json())?.data ?? {};
|
|
|
|
// Create the case if it doesn't exist
|
|
if (!caseData.path) {
|
|
const createCaseRes = await fetch('/api/cases', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: caseName, description: '' })
|
|
});
|
|
const createCaseData = await createCaseRes.json();
|
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
|
// API returns { success, data: { case: { name, path } } }
|
|
caseData = createCaseData.data.case;
|
|
}
|
|
|
|
const workingDir = caseData.path;
|
|
if (!workingDir) throw new Error('Case path not found');
|
|
|
|
// 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' || 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 quickStartBody = JSON.stringify({
|
|
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
|
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
|
});
|
|
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');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
remoteIds.push(data.data.sessionId);
|
|
}
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
|
if (remoteIds[0]) {
|
|
await this.selectSession(remoteIds[0]);
|
|
this.loadQuickStartCases();
|
|
}
|
|
this.terminal.focus();
|
|
return;
|
|
}
|
|
|
|
let firstSessionId = null;
|
|
|
|
// Find the highest existing w-number for THIS case to avoid duplicates
|
|
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
|
|
|
// Get global Ralph tracker setting
|
|
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
|
|
|
// Create all sessions in parallel for speed
|
|
const sessionNames = [];
|
|
for (let i = 0; i < tabCount; i++) {
|
|
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
|
}
|
|
|
|
// Build env overrides from global + case settings (case overrides global)
|
|
const caseSettings = this.getCaseSettings(caseName);
|
|
const globalSettings = this.loadAppSettingsFromStorage();
|
|
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
|
|
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
|
const effort = this.getEffortSetting(globalSettings);
|
|
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
|
|
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
|
|
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
|
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
|
|
|
// Step 1: Create all sessions in parallel
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`);
|
|
const createPromises = sessionNames.map(name =>
|
|
fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
workingDir, name,
|
|
...(hasEnvOverrides ? { envOverrides } : {}),
|
|
...(effort ? { effort } : {}),
|
|
...(modelOverride !== undefined ? { modelOverride } : {}),
|
|
})
|
|
}).then(r => r.json())
|
|
);
|
|
const createResults = await Promise.all(createPromises);
|
|
|
|
// Collect created session IDs
|
|
const sessionIds = [];
|
|
for (const result of createResults) {
|
|
if (!result.success) throw new Error(result.error);
|
|
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
|
sessionIds.push(result.data.session.id);
|
|
}
|
|
firstSessionId = sessionIds[0];
|
|
|
|
// Step 2: Configure Ralph for all sessions in parallel
|
|
await Promise.all(sessionIds.map(id =>
|
|
fetch(`/api/sessions/${id}/ralph-config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled })
|
|
})
|
|
));
|
|
|
|
// Step 3: Start all sessions in parallel (biggest speedup)
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`);
|
|
await Promise.all(sessionIds.map(id =>
|
|
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
|
));
|
|
|
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`);
|
|
|
|
// Auto-switch to the new session using selectSession (does proper refresh)
|
|
if (firstSessionId) {
|
|
await this.selectSession(firstSessionId);
|
|
this.loadQuickStartCases();
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/** Send Ctrl+C to the active session to stop the current operation.
|
|
* Requires double-tap: first tap turns button amber, second tap within 2s sends Ctrl+C. */
|
|
stopClaude() {
|
|
if (!this.activeSessionId) return;
|
|
const btn = document.querySelector('.btn-toolbar.btn-stop');
|
|
if (!btn) return;
|
|
|
|
if (this._stopConfirmTimer) {
|
|
// Second tap — send Ctrl+C
|
|
clearTimeout(this._stopConfirmTimer);
|
|
this._stopConfirmTimer = null;
|
|
btn.innerHTML = btn.dataset.origHtml;
|
|
delete btn.dataset.origHtml;
|
|
btn.classList.remove('confirming');
|
|
fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ input: '\x03' })
|
|
});
|
|
} else {
|
|
// First tap — enter confirm state
|
|
btn.dataset.origHtml = btn.innerHTML;
|
|
btn.textContent = 'Tap again';
|
|
btn.classList.add('confirming');
|
|
this._stopConfirmTimer = setTimeout(() => {
|
|
this._stopConfirmTimer = null;
|
|
if (btn.dataset.origHtml) {
|
|
btn.innerHTML = btn.dataset.origHtml;
|
|
delete btn.dataset.origHtml;
|
|
}
|
|
btn.classList.remove('confirming');
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
async runShell() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
|
'1;33'
|
|
);
|
|
|
|
try {
|
|
// Get the case path
|
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
|
let caseData = (await caseRes.json())?.data ?? {};
|
|
|
|
// Create the case if it doesn't exist
|
|
if (!caseData.path) {
|
|
const createCaseRes = await fetch('/api/cases', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: caseName, description: '' })
|
|
});
|
|
const createCaseData = await createCaseRes.json();
|
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
|
// API returns { success, data: { case: { name, path } } }
|
|
caseData = createCaseData.data.case;
|
|
}
|
|
|
|
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
|
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' || 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', sessionName: `s${startNumber + i}-${caseName}` })
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
remoteIds.push(data.data.sessionId);
|
|
}
|
|
if (remoteIds[0]) {
|
|
// Don't pre-set activeSessionId — selectSession early-returns when the
|
|
// IDs match, skipping the buffer load, tab activation, and focus (see runCodex).
|
|
await this.selectSession(remoteIds[0]);
|
|
}
|
|
this.terminal.focus();
|
|
return;
|
|
}
|
|
|
|
// Find the highest existing s-number for THIS case to avoid duplicates
|
|
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
|
|
|
// Create all shell sessions in parallel
|
|
const sessionNames = [];
|
|
for (let i = 0; i < shellCount; i++) {
|
|
sessionNames.push(`s${startNumber + i}-${caseName}`);
|
|
}
|
|
|
|
// Step 1: Create all sessions in parallel
|
|
const createPromises = sessionNames.map(name =>
|
|
fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
|
|
}).then(r => r.json())
|
|
);
|
|
const createResults = await Promise.all(createPromises);
|
|
|
|
const sessionIds = [];
|
|
for (const result of createResults) {
|
|
if (!result.success) throw new Error(result.error);
|
|
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
|
sessionIds.push(result.data.session.id);
|
|
}
|
|
|
|
// Step 2: Start all shells in parallel
|
|
await Promise.all(sessionIds.map(id =>
|
|
fetch(`/api/sessions/${id}/shell`, { method: 'POST' })
|
|
));
|
|
|
|
// Step 3: Resize all in parallel (with minimum dimension enforcement)
|
|
const dims = this.getTerminalDimensions();
|
|
if (dims) {
|
|
await Promise.all(sessionIds.map(id =>
|
|
fetch(`/api/sessions/${id}/resize`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(dims)
|
|
})
|
|
));
|
|
}
|
|
|
|
// Switch to first session. Don't pre-set activeSessionId — selectSession
|
|
// early-returns when the IDs match, skipping the buffer load, tab
|
|
// activation, and focus (see runCodex), which left the new shell tab
|
|
// created but not shown until the user manually clicked it.
|
|
if (sessionIds.length > 0) {
|
|
await this.selectSession(sessionIds[0]);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
async runOpenCode() {
|
|
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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
|
|
// Focus in sync gesture context (see runClaude comment)
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
// Check if OpenCode is available (local sessions only)
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/opencode/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Quick-start with opencode mode (auto-allow tools by default).
|
|
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'opencode',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
openCodeConfig: { autoAllowTools: true },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
|
// early-returns when IDs match, skipping buffer load and sendResize)
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
async runCodex() {
|
|
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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/codex/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const globalSettings = this.loadAppSettingsFromStorage();
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'codex',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
codexConfig: {
|
|
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
|
animations: globalSettings.codexAnimationsEnabled ?? false,
|
|
renderMode: 'hybrid',
|
|
},
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
|
// early-returns when IDs match, skipping buffer load and sendResize)
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
async runGemini() {
|
|
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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/gemini/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'gemini',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
geminiConfig: { approvalMode: 'yolo' },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
async runAntigravity() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
|
// local-only config/env below (quick-start rejects them for remote cases).
|
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/antigravity/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const res = await fetch('/api/quick-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
caseName,
|
|
mode: 'antigravity',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
antigravityConfig: { dangerouslySkipPermissions: true },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
}),
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Failed to start Antigravity');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Launch a Pi (pi.dev) session.
|
|
*
|
|
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
|
|
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
|
* decision (an interactive prompt the user answers in the terminal). Sending
|
|
* `approveProjectTrust: true` here would silently opt every browser-launched pi
|
|
* session into executing repo-supplied TypeScript.
|
|
*/
|
|
async runPi() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
|
|
// local-only config/env below (quick-start rejects them for remote cases).
|
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/pi/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'pi',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
|
|
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start Pi');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
async runOmp() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
|
// and the local-only config below (quick-start rejects them for remote cases).
|
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/omp/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'omp',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start OMP');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Launch a Grok Build (xAI `grok`) session.
|
|
*
|
|
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
|
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
|
* work, so the Run button opts into grok's bypassPermissions mode
|
|
* (`--always-approve`; config-level deny rules still apply on top). The
|
|
* multi-user clamp forces it back off for non-granted owners server-side.
|
|
*/
|
|
async runGrok() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
|
// local-only config/env below (quick-start rejects them for remote cases).
|
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/grok/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'grok',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
grokConfig: { alwaysApprove: true },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start Grok');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Launch a DeepSeek Harness (`dsh`) session.
|
|
*
|
|
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
|
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
|
* autonomous work. The harness has no bypass FLAG, so this rides the
|
|
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
|
* back down to `workspace-write` for non-granted owners server-side.
|
|
*
|
|
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
|
* output-stabilization guessing to definitive idle/blocked hook events.
|
|
*
|
|
* The two-part availability check is deliberate. `dsh` being installed is not
|
|
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
|
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
|
* command that fixes it, is the difference between "the Run button is broken"
|
|
* and a 30-second fix.
|
|
*/
|
|
async runDeepSeek() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
|
// local-only config/env below (quick-start rejects them for remote cases).
|
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
|
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
|
|
this.terminal.focus();
|
|
|
|
try {
|
|
if (!isRemote) {
|
|
const statusRes = await fetch('/api/deepseek/status');
|
|
const status = (await statusRes.json()).data;
|
|
if (!status.available) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
|
);
|
|
return;
|
|
}
|
|
if (!status.runnable) {
|
|
this._reportSessionLaunchError(
|
|
ownsLaunchTerminal,
|
|
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
|
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
|
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
const data = await this._quickStartWithCustomModelConfirm({
|
|
caseName,
|
|
mode: 'deepseek',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
}),
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
|
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
|
|
|
if (data.data.sessionId) {
|
|
await this.selectSession(data.data.sessionId);
|
|
}
|
|
|
|
this.terminal.focus();
|
|
} catch (err) {
|
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
}
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Options Modal
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Per-TAB pop-out button override (Session Options → Session → Identity). The
|
|
* general `showTabDetachButton` App Setting stays the per-device default for ALL
|
|
* tabs; this map whitelists single sessions on top of it, so one tab can carry
|
|
* the ⧉ button while the general toggle stays off. Per-device on purpose, like
|
|
* the general setting: it is a display choice, so it lives in localStorage and
|
|
* never touches the server schema. Rendered as the `tab-show-detach` class on
|
|
* the tab (see _fullRenderSessionTabs), which styles.css exempts from the
|
|
* global `display: none` gate; the active-tab reveal rules stay shared, so an
|
|
* overridden tab behaves exactly like a tab under the general toggle.
|
|
*/
|
|
_tabDetachOverrides() {
|
|
if (this._tabDetachOverrideMap === undefined) {
|
|
try {
|
|
this._tabDetachOverrideMap = JSON.parse(localStorage.getItem('codeman:tab-detach-overrides') || '{}') || {};
|
|
} catch (_e) {
|
|
this._tabDetachOverrideMap = {};
|
|
}
|
|
}
|
|
return this._tabDetachOverrideMap;
|
|
},
|
|
|
|
hasTabDetachOverride(sessionId) {
|
|
return !!this._tabDetachOverrides()[sessionId];
|
|
},
|
|
|
|
onSessionTabDetachToggle(on) {
|
|
const id = this.editingSessionId;
|
|
if (!id) return;
|
|
const map = this._tabDetachOverrides();
|
|
if (on) map[id] = 1;
|
|
else delete map[id];
|
|
// Prune ids whose sessions are gone, so closed sessions cannot grow the map.
|
|
for (const key of Object.keys(map)) {
|
|
if (key !== id && this.sessions && !this.sessions.has(key)) delete map[key];
|
|
}
|
|
try {
|
|
localStorage.setItem('codeman:tab-detach-overrides', JSON.stringify(map));
|
|
} catch (_e) {
|
|
/* storage full/blocked: the in-memory map still applies this page load */
|
|
}
|
|
// Apply to the LIVE tab directly: the debounced render may take the
|
|
// incremental path (same session set), which patches rather than rebuilds,
|
|
// so the template's class would only land on the next full render. Future
|
|
// full renders re-emit it from _fullRenderSessionTabs.
|
|
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
|
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
|
},
|
|
|
|
openSessionOptions(sessionId) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
this.editingSessionId = sessionId;
|
|
|
|
// Per-tab pop-out override state (see _tabDetachOverrides above).
|
|
const detachToggle = document.getElementById('sessionOptShowTabDetach');
|
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
|
|
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
|
|
|
// Update respawn status display and buttons
|
|
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
|
const enableBtn = document.getElementById('modalEnableRespawnBtn');
|
|
const stopBtn = document.getElementById('modalStopRespawnBtn');
|
|
|
|
if (this.respawnStatus[sessionId]) {
|
|
respawnStatus.classList.add('active');
|
|
respawnStatus.querySelector('.respawn-status-text').textContent =
|
|
this.respawnStatus[sessionId].state || 'Active';
|
|
enableBtn.style.display = 'none';
|
|
stopBtn.style.display = '';
|
|
} else {
|
|
respawnStatus.classList.remove('active');
|
|
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
|
|
enableBtn.style.display = '';
|
|
stopBtn.style.display = 'none';
|
|
}
|
|
|
|
// Only show respawn section for claude mode sessions with a running process
|
|
const respawnSection = document.getElementById('sessionRespawnSection');
|
|
if (session.mode === 'claude' && session.pid) {
|
|
respawnSection.style.display = '';
|
|
} else {
|
|
respawnSection.style.display = 'none';
|
|
}
|
|
|
|
// Hide Claude-specific options for external CLI sessions
|
|
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
|
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
|
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
|
|
|
// Reset duration presets to default (unlimited)
|
|
this.selectDurationPreset('');
|
|
|
|
// Populate respawn config from saved state
|
|
this.loadSavedRespawnConfig(sessionId);
|
|
|
|
// Populate auto-compact/clear from session state
|
|
document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false;
|
|
document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000;
|
|
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
|
|
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
|
|
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
|
|
|
|
// Populate auto-resume on usage limit (token pause control)
|
|
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
|
this.updateAutoResumeStatus(sessionId);
|
|
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
|
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
|
|
|
// Populate session name input with prefix/suffix split
|
|
const _modalParsed = parseSessionPrefix(session.name);
|
|
const _prefixEl = document.getElementById('modalSessionPrefix');
|
|
if (_modalParsed) {
|
|
_prefixEl.textContent = _modalParsed.prefix + ': ';
|
|
_prefixEl.style.display = '';
|
|
document.getElementById('modalSessionName').value = _modalParsed.suffix;
|
|
document.getElementById('modalSessionName').placeholder = 'Add description...';
|
|
} else {
|
|
_prefixEl.style.display = 'none';
|
|
_prefixEl.textContent = '';
|
|
document.getElementById('modalSessionName').value = session.name || '';
|
|
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
|
}
|
|
|
|
// Initialize color picker with current session color
|
|
const currentColor = session.color || 'default';
|
|
const colorPicker = document.getElementById('sessionColorPicker');
|
|
colorPicker?.querySelectorAll('.color-swatch').forEach(s => {
|
|
s.classList.toggle('selected', s.dataset.color === currentColor);
|
|
});
|
|
|
|
// Initialize respawn preset dropdown
|
|
this.renderPresetDropdown();
|
|
document.getElementById('respawnPresetSelect').value = '';
|
|
document.getElementById('presetDescriptionHint').textContent = '';
|
|
|
|
// Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported)
|
|
const ralphTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="ralph"]');
|
|
const respawnTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="respawn"]');
|
|
if (isExternalCli) {
|
|
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
|
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
|
|
// Default to Context tab for external CLI sessions since Respawn is hidden
|
|
this.switchOptionsTab('context');
|
|
} else {
|
|
if (ralphTabBtn) ralphTabBtn.style.display = '';
|
|
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
|
}
|
|
|
|
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
|
|
if (!isExternalCli) {
|
|
const ralphState = this.ralphStates.get(sessionId);
|
|
this.populateRalphForm({
|
|
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
|
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
|
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
|
maxTodos: ralphState?.loop?.maxTodos || session.ralphLoop?.maxTodos,
|
|
todoExpirationMinutes: ralphState?.loop?.todoExpirationMinutes || session.ralphLoop?.todoExpirationMinutes,
|
|
});
|
|
}
|
|
|
|
const modal = document.getElementById('sessionOptionsModal');
|
|
|
|
// Chips mirror their checkbox onto the label, the same way App Settings does
|
|
// (settings-ui.js: _syncSettingsChips). Registered once per page, never per
|
|
// open, or a long-lived tab accumulates one listener per visit.
|
|
if (modal.dataset.chipsReady !== '1') {
|
|
modal.dataset.chipsReady = '1';
|
|
modal.addEventListener('change', e => {
|
|
if (e.target?.closest?.('.set-chip')) this._syncSettingsChips();
|
|
});
|
|
}
|
|
this._syncSettingsChips();
|
|
|
|
modal.classList.add('active');
|
|
|
|
// Activate focus trap
|
|
this.activeFocusTrap = new FocusTrap(modal);
|
|
this.activeFocusTrap.activate();
|
|
},
|
|
|
|
/**
|
|
* Write a name the server has just confirmed into the local session map.
|
|
*
|
|
* Both rename surfaces re-render the tab strip from `this.sessions` right
|
|
* after their PUT, so without this they depended on the `session:updated` SSE
|
|
* frame to carry their own write back. On a page whose SSE stream has gone
|
|
* quiet without erroring (a proxy that idle-closed it, a laptop resumed from
|
|
* sleep) that frame never lands: the PUT stores the new name, the re-render
|
|
* repaints the stale one, and the rename looks like it did nothing until a
|
|
* full page reload. The response body is authoritative, so apply it directly.
|
|
* The SSE frame, when it does arrive, replaces the object with the same name.
|
|
*/
|
|
_applyLocalSessionName(sessionId, name) {
|
|
if (typeof name !== 'string') return;
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
session.name = name;
|
|
this.sessions.set(sessionId, session);
|
|
// Mirrors _onSessionUpdated: subagent windows cache their parent's name.
|
|
this.updateSubagentParentNames?.(sessionId);
|
|
},
|
|
|
|
/**
|
|
* PUT a session name and return the name the server stored, or null if the
|
|
* request failed. `_apiPut` swallows network errors into a null Response and
|
|
* an API-level failure arrives as a non-ok status or `{success:false}`, so a
|
|
* rename that silently did nothing has to be detected here, not thrown.
|
|
*/
|
|
async _putSessionName(sessionId, name) {
|
|
const res = await this._apiPut(`/api/sessions/${sessionId}/name`, { name });
|
|
if (!res || !res.ok) return null;
|
|
let payload = null;
|
|
try {
|
|
payload = await res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (payload && payload.success === false) return null;
|
|
const confirmed = payload?.data?.name;
|
|
return typeof confirmed === 'string' ? confirmed : name;
|
|
},
|
|
|
|
async saveSessionName() {
|
|
if (!this.editingSessionId) return;
|
|
// Captured: the modal can be closed (or switched to another session) while
|
|
// the PUT is in flight, and the name belongs to the session that was open.
|
|
const sessionId = this.editingSessionId;
|
|
const session = this.sessions.get(sessionId);
|
|
const parsed = session ? parseSessionPrefix(session.name) : null;
|
|
const inputVal = document.getElementById('modalSessionName').value.trim();
|
|
let name;
|
|
if (parsed) {
|
|
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
|
|
} else {
|
|
name = inputVal;
|
|
}
|
|
const confirmed = await this._putSessionName(sessionId, name);
|
|
if (confirmed === null) {
|
|
this.showToast('Failed to save session name', 'error');
|
|
return;
|
|
}
|
|
this._applyLocalSessionName(sessionId, confirmed);
|
|
this.renderSessionTabs();
|
|
},
|
|
|
|
async autoSaveAutoCompact() {
|
|
if (!this.editingSessionId) return;
|
|
try {
|
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
|
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
|
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
|
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
|
});
|
|
} catch { /* silent */ }
|
|
},
|
|
|
|
async autoSaveAutoClear() {
|
|
if (!this.editingSessionId) return;
|
|
try {
|
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
|
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
|
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
|
});
|
|
} catch { /* silent */ }
|
|
},
|
|
|
|
async autoSaveAutoResume() {
|
|
if (!this.editingSessionId) return;
|
|
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
|
|
try {
|
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
|
|
const session = this.sessions.get(this.editingSessionId);
|
|
if (session) {
|
|
session.autoResumeEnabled = enabled;
|
|
if (!enabled) session.autoResumeAt = undefined;
|
|
}
|
|
this.updateAutoResumeStatus(this.editingSessionId);
|
|
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Show "resumes at HH:MM" in the session options modal while a usage-limit
|
|
// pause is armed for the session being edited
|
|
updateAutoResumeStatus(sessionId) {
|
|
const el = document.getElementById('autoResumeStatus');
|
|
if (!el || this.editingSessionId !== sessionId) return;
|
|
const session = this.sessions.get(sessionId);
|
|
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
|
|
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
el.textContent = `Usage limit pause active — resumes at ${at}`;
|
|
el.classList.add('active');
|
|
} else {
|
|
el.textContent = '';
|
|
el.classList.remove('active');
|
|
}
|
|
},
|
|
|
|
async toggleSessionImageWatcher() {
|
|
if (!this.editingSessionId) return;
|
|
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
|
try {
|
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
|
|
// Update local session state
|
|
const session = this.sessions.get(this.editingSessionId);
|
|
if (session) {
|
|
session.imageWatcherEnabled = enabled;
|
|
}
|
|
this.showToast(`Image watcher ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to toggle image watcher', 'error');
|
|
}
|
|
},
|
|
|
|
async toggleFlickerFilter() {
|
|
if (!this.editingSessionId) return;
|
|
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
|
|
try {
|
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled });
|
|
// Update local session state
|
|
const session = this.sessions.get(this.editingSessionId);
|
|
if (session) {
|
|
session.flickerFilterEnabled = enabled;
|
|
}
|
|
this.showToast(`Flicker filter ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to toggle flicker filter', 'error');
|
|
}
|
|
},
|
|
|
|
async autoSaveRespawnConfig() {
|
|
if (!this.editingSessionId) return;
|
|
const config = {
|
|
updatePrompt: document.getElementById('modalRespawnPrompt').value,
|
|
sendClear: document.getElementById('modalRespawnSendClear').checked,
|
|
sendInit: document.getElementById('modalRespawnSendInit').checked,
|
|
kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined,
|
|
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
|
|
};
|
|
try {
|
|
await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config);
|
|
} catch {
|
|
// Silent save - don't interrupt user
|
|
}
|
|
},
|
|
|
|
async loadSavedRespawnConfig(sessionId) {
|
|
try {
|
|
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
|
|
const data = await res.json();
|
|
if (data.success && data.data && data.data.config) {
|
|
const c = data.data.config;
|
|
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
|
|
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
|
|
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
|
|
document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || '';
|
|
document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true;
|
|
// Restore duration if set
|
|
if (c.durationMinutes) {
|
|
const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`);
|
|
if (presetBtn) {
|
|
this.selectDurationPreset(String(c.durationMinutes));
|
|
} else {
|
|
this.selectDurationPreset('custom');
|
|
document.getElementById('modalRespawnDuration').value = c.durationMinutes;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore - use defaults
|
|
}
|
|
},
|
|
|
|
// Handle duration preset selection
|
|
selectDurationPreset(value) {
|
|
// Remove active from all buttons
|
|
document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active'));
|
|
|
|
// Find and activate the clicked button
|
|
const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`);
|
|
if (btn) btn.classList.add('active');
|
|
|
|
// Show/hide custom input
|
|
const customInput = document.querySelector('.duration-custom-input');
|
|
const durationInput = document.getElementById('modalRespawnDuration');
|
|
|
|
if (value === 'custom') {
|
|
customInput.classList.add('visible');
|
|
durationInput.focus();
|
|
} else {
|
|
customInput.classList.remove('visible');
|
|
durationInput.value = ''; // Clear custom value when using preset
|
|
}
|
|
},
|
|
|
|
// Get selected duration from preset buttons or custom input
|
|
getSelectedDuration() {
|
|
const customInput = document.querySelector('.duration-custom-input');
|
|
const durationInput = document.getElementById('modalRespawnDuration');
|
|
|
|
if (customInput.classList.contains('visible')) {
|
|
// Custom mode - use input value
|
|
return durationInput.value ? parseInt(durationInput.value) : null;
|
|
} else {
|
|
// Preset mode - get from active button
|
|
const activeBtn = document.querySelector('.duration-preset-btn.active');
|
|
const minutes = activeBtn?.dataset.minutes;
|
|
return minutes ? parseInt(minutes) : null;
|
|
}
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Options Modal Tabs
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Show one section of the Session Options modal.
|
|
*
|
|
* The chrome is the shared `set-*` settings surface, but unlike App Settings
|
|
* (whose rail is a table of contents over one scrolling document) this rail
|
|
* is a real switcher: exactly one `.set-section` is visible and the rest
|
|
* carry `.hidden`. Summary owns its own scroller and Respawn is long, so
|
|
* stacking them into a single document would bury both.
|
|
*/
|
|
switchOptionsTab(tabName) {
|
|
// Toggle active class on rail entries
|
|
document.querySelectorAll('#sessionOptionsModal .set-rail-item').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
|
|
// Toggle hidden class on the sections
|
|
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
|
|
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
|
|
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
|
|
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
|
|
|
|
// A switched-to section starts at its own top, not at the scroll offset the
|
|
// previous one was left at.
|
|
const doc = document.getElementById('sessionOptionsDoc');
|
|
if (doc) doc.scrollTop = 0;
|
|
|
|
// Load run summary data when switching to summary tab
|
|
if (tabName === 'summary' && this.editingSessionId) {
|
|
this.loadRunSummary(this.editingSessionId);
|
|
}
|
|
},
|
|
|
|
getRalphConfig() {
|
|
return {
|
|
enabled: document.getElementById('modalRalphEnabled').checked,
|
|
completionPhrase: document.getElementById('modalRalphPhrase').value.trim(),
|
|
maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0,
|
|
maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50,
|
|
todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60
|
|
};
|
|
},
|
|
|
|
populateRalphForm(config) {
|
|
document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false;
|
|
document.getElementById('modalRalphPhrase').value = config?.completionPhrase || '';
|
|
document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0;
|
|
document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50;
|
|
document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60;
|
|
},
|
|
|
|
async saveRalphConfig() {
|
|
if (!this.editingSessionId) {
|
|
this.showToast('No session selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
const config = this.getRalphConfig();
|
|
|
|
// If user is enabling Ralph, clear from closed set
|
|
if (config.enabled) {
|
|
this.ralphClosedSessions.delete(this.editingSessionId);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(config)
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
|
|
this.showToast('Ralph config saved', 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to save Ralph config: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Inline rename on right-click
|
|
startInlineRename(sessionId) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
this._activeRename?.cancel();
|
|
|
|
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
|
if (!tabName) return;
|
|
|
|
// Prevent tab re-renders from destroying the input while renaming
|
|
this._inlineRenameActive = true;
|
|
tabName.classList.add('tab-name-renaming');
|
|
|
|
const currentName = this.getSessionName(session);
|
|
const parsed = parseSessionPrefix(session.name);
|
|
const originalContent = tabName.textContent;
|
|
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
|
const restoreOriginalChildren = () => {
|
|
tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true)));
|
|
};
|
|
// Clear existing content to make room for the input element
|
|
tabName.textContent = '';
|
|
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
|
|
|
|
// If prefix detected, show it as non-editable label
|
|
if (parsed) {
|
|
const prefixLabel = document.createElement('span');
|
|
prefixLabel.className = 'tab-rename-prefix';
|
|
prefixLabel.textContent = parsed.prefix + ': ';
|
|
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
|
tabName.appendChild(prefixLabel);
|
|
}
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = parsed ? parsed.suffix : (session.name || '');
|
|
input.placeholder = parsed ? 'Add description...' : currentName;
|
|
input.className = 'tab-rename-input';
|
|
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
|
// should give the whole line to the input.
|
|
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
|
|
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
|
|
|
tabName.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
let editSettled = false;
|
|
let invalidated = false;
|
|
let completed = false;
|
|
|
|
const releaseRenderGuard = () => {
|
|
if (this._activeRename !== renameHandle) return;
|
|
this._inlineRenameActive = false;
|
|
};
|
|
|
|
const completeCurrentRename = () => {
|
|
if (this._activeRename !== renameHandle) return;
|
|
completed = true;
|
|
releaseRenderGuard();
|
|
this._activeRename = null;
|
|
this.renderSessionTabs();
|
|
};
|
|
|
|
const cancelRename = () => {
|
|
if (invalidated || completed) return;
|
|
invalidated = true;
|
|
editSettled = true;
|
|
tabName.classList.remove('tab-name-renaming');
|
|
restoreOriginalChildren();
|
|
completeCurrentRename();
|
|
};
|
|
|
|
const finishRename = async ({ commit }) => {
|
|
if (editSettled || invalidated) return;
|
|
editSettled = true;
|
|
tabName.classList.remove('tab-name-renaming');
|
|
|
|
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
|
|
// so any ghost DOM is replaced with the canonical tab list, and skip the
|
|
// API call — a cancel must not fire a stale rename PUT.
|
|
if (!commit) {
|
|
cancelRename();
|
|
return;
|
|
}
|
|
|
|
if (this._activeRename !== renameHandle) return;
|
|
releaseRenderGuard();
|
|
|
|
const suffix = input.value.trim();
|
|
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
|
if (fullName === session.name) restoreOriginalChildren();
|
|
else tabName.textContent = fullName || originalContent;
|
|
|
|
// Skip the API call if the session vanished between focus and blur.
|
|
const stillExists = this.sessions.has(sessionId);
|
|
if (stillExists && fullName !== session.name) {
|
|
const confirmed = await this._putSessionName(sessionId, fullName);
|
|
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
|
|
if (confirmed === null) {
|
|
restoreOriginalChildren();
|
|
this.showToast('Failed to rename', 'error');
|
|
} else {
|
|
// The re-render below repaints from this.sessions, so the new name has
|
|
// to be in the map before it runs (see _applyLocalSessionName()).
|
|
this._applyLocalSessionName(sessionId, confirmed);
|
|
}
|
|
}
|
|
// Re-render tabs to restore full tab structure
|
|
completeCurrentRename();
|
|
};
|
|
|
|
// Register only after the input is wired so a throw above can't strand state.
|
|
const renameHandle = {
|
|
sessionId,
|
|
cancel: cancelRename,
|
|
};
|
|
this._activeRename = renameHandle;
|
|
|
|
input.addEventListener('blur', () => finishRename({ commit: true }));
|
|
input.addEventListener('keydown', (e) => {
|
|
// Enter/Escape during IME composition belong to the IME (e.g. confirming
|
|
// a Chinese pinyin candidate). keyCode 229 is the legacy signal for the
|
|
// same condition on browsers that don't set isComposing reliably.
|
|
if (e.isComposing || e.keyCode === 229) return;
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
input.blur();
|
|
} else if (e.key === 'Escape') {
|
|
// Cancel, never commit. This used to clear the field and blur, and the
|
|
// blur handler commits — so Escape RENAMED the session to an empty
|
|
// string (measured: the tab fell back to its folder name and the server
|
|
// stored ""), in every layout. cancelRename() marks the edit
|
|
// invalidated, so the blur that follows the input's removal is a no-op.
|
|
e.preventDefault();
|
|
cancelRename();
|
|
}
|
|
});
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Case Settings
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
toggleCaseSettings() {
|
|
const popover = document.getElementById('caseSettingsPopover');
|
|
if (popover.classList.contains('hidden')) {
|
|
// Load settings for current case
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const settings = this.getCaseSettings(caseName);
|
|
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
|
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
|
|
popover.classList.remove('hidden');
|
|
|
|
// Close on outside click (one-shot listener)
|
|
const closeHandler = (e) => {
|
|
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
|
|
popover.classList.add('hidden');
|
|
document.removeEventListener('click', closeHandler);
|
|
}
|
|
};
|
|
// Defer to avoid catching the current click
|
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
} else {
|
|
popover.classList.add('hidden');
|
|
}
|
|
},
|
|
|
|
getCaseSettings(caseName) {
|
|
try {
|
|
const stored = localStorage.getItem('caseSettings_' + caseName);
|
|
if (stored) return JSON.parse(stored);
|
|
} catch { /* ignore */ }
|
|
return { agentTeams: false, opusContext1m: true };
|
|
},
|
|
|
|
saveCaseSettings(caseName, settings) {
|
|
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
|
|
},
|
|
|
|
onCaseSettingChanged() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const settings = this.getCaseSettings(caseName);
|
|
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
|
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
|
|
this.saveCaseSettings(caseName, settings);
|
|
// Sync mobile checkboxes
|
|
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
|
|
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
|
|
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
|
|
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
|
|
},
|
|
|
|
toggleCaseSettingsMobile() {
|
|
const popover = document.getElementById('caseSettingsPopoverMobile');
|
|
if (popover.classList.contains('hidden')) {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const settings = this.getCaseSettings(caseName);
|
|
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
|
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
|
|
popover.classList.remove('hidden');
|
|
|
|
const closeHandler = (e) => {
|
|
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
|
|
popover.classList.add('hidden');
|
|
document.removeEventListener('click', closeHandler);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
} else {
|
|
popover.classList.add('hidden');
|
|
}
|
|
},
|
|
|
|
onCaseSettingChangedMobile() {
|
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
const settings = this.getCaseSettings(caseName);
|
|
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
|
|
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
|
|
this.saveCaseSettings(caseName, settings);
|
|
// Sync desktop checkboxes
|
|
const desktopCheckbox = document.getElementById('caseAgentTeams');
|
|
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
|
|
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
|
|
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Create Case Modal
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
showCreateCaseModal() {
|
|
document.getElementById('newCaseName').value = '';
|
|
document.getElementById('newCaseDescription').value = '';
|
|
document.getElementById('linkCaseName').value = '';
|
|
document.getElementById('linkCasePath').value = '';
|
|
const remoteFields = [
|
|
'remoteCaseName',
|
|
'remoteCasePath',
|
|
'remoteHostId',
|
|
'remoteHostAddress',
|
|
'remoteHostUsername',
|
|
'remoteHostPort',
|
|
'remoteHostCodexCommand',
|
|
'remoteHostIdentityFile',
|
|
'remoteHostSocksProxy',
|
|
'remoteHostJumpHost',
|
|
'remoteHostExtraSshOptions',
|
|
];
|
|
remoteFields.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = '';
|
|
});
|
|
this._resetCloneForm();
|
|
// Cloning needs git ON THE SERVER: hide the whole tab rather than let it fail
|
|
// at submit. Unknown reads as available (isCliAvailable's rule).
|
|
const cloneTabBtn = document.getElementById('caseCloneTabBtn');
|
|
if (cloneTabBtn) cloneTabBtn.style.display = this.isCliAvailable('git') ? '' : 'none';
|
|
// Reset to first tab
|
|
this.caseModalTab = 'case-create';
|
|
this.switchCaseModalTab('case-create');
|
|
// Wire up tab buttons
|
|
const modal = document.getElementById('createCaseModal');
|
|
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
|
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
|
});
|
|
// Adopt-an-existing-container toggle + its read-only preflight. Assigned (not
|
|
// addEventListener) so reopening the modal cannot stack duplicate handlers,
|
|
// matching the rail wiring right above.
|
|
const adoptToggle = document.getElementById('dockerAdoptExisting');
|
|
if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode();
|
|
const adoptCheck = document.getElementById('dockerAdoptCheckBtn');
|
|
if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight();
|
|
const adoptJump = document.getElementById('dockerAdoptJumpBtn');
|
|
if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt();
|
|
// Containers come from the host profile, so switching Host ID invalidates the
|
|
// suggestions. Dropping the marker (rather than refetching here) keeps the
|
|
// fetch lazy — it happens when adopt mode is actually on.
|
|
const hostIdInput = document.getElementById('dockerHostId');
|
|
if (hostIdInput) {
|
|
hostIdInput.onchange = () => {
|
|
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
|
if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions();
|
|
};
|
|
}
|
|
// A fresh open re-reads the engine: containers start and stop between visits.
|
|
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
|
this._syncDockerAdoptMode();
|
|
// Scroll-into-view on focus for mobile keyboard visibility
|
|
modal.querySelectorAll('input[type="text"]').forEach(input => {
|
|
if (!input._mobileScrollWired) {
|
|
input._mobileScrollWired = true;
|
|
input.addEventListener('focus', () => {
|
|
if (window.innerWidth < 600) {
|
|
setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
modal.classList.add('active');
|
|
document.getElementById('newCaseName').focus();
|
|
},
|
|
|
|
switchCaseModalTab(tabName) {
|
|
this.caseModalTab = tabName;
|
|
const modal = document.getElementById('createCaseModal');
|
|
// Toggle active class on rail entries
|
|
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
// Toggle hidden class on the panels
|
|
modal.querySelectorAll('.set-section').forEach(content => {
|
|
content.classList.toggle('hidden', content.id !== tabName);
|
|
});
|
|
// A switched-to panel starts at its own top.
|
|
const doc = document.getElementById('createCaseDoc');
|
|
if (doc) doc.scrollTop = 0;
|
|
// Update submit buttons (hide for manage tab). Two of them: mobile.css hides
|
|
// this modal's .set-foot, so phones submit through the header button instead.
|
|
const submitBtns = ['caseModalSubmit', 'caseModalSubmitMobile']
|
|
.map((id) => document.getElementById(id))
|
|
.filter(Boolean);
|
|
if (tabName === 'case-manage') {
|
|
submitBtns.forEach((btn) => {
|
|
btn.style.display = 'none';
|
|
});
|
|
this.renderCaseManageList();
|
|
this.refreshDockerExports();
|
|
} else {
|
|
const label =
|
|
tabName === 'case-create'
|
|
? 'Create'
|
|
: tabName === 'case-clone'
|
|
? 'Clone'
|
|
: tabName === 'case-remote'
|
|
? 'Link Remote'
|
|
: tabName === 'case-docker'
|
|
? 'Link Docker'
|
|
: 'Link';
|
|
submitBtns.forEach((btn) => {
|
|
btn.style.display = '';
|
|
btn.textContent = label;
|
|
});
|
|
}
|
|
// Focus appropriate input
|
|
if (tabName === 'case-create') {
|
|
document.getElementById('newCaseName').focus();
|
|
} else if (tabName === 'case-clone') {
|
|
document.getElementById('cloneRepoUrl').focus();
|
|
} else if (tabName === 'case-link') {
|
|
document.getElementById('linkCaseName').focus();
|
|
} else if (tabName === 'case-remote') {
|
|
document.getElementById('remoteCaseName').focus();
|
|
} else if (tabName === 'case-docker') {
|
|
document.getElementById('dockerCaseName').focus();
|
|
}
|
|
},
|
|
|
|
closeCreateCaseModal() {
|
|
document.getElementById('createCaseModal').classList.remove('active');
|
|
},
|
|
|
|
async submitCaseModal() {
|
|
// Both submit buttons move together: whichever one the user pressed, the
|
|
// other must show the same pending state and be equally unclickable.
|
|
const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean);
|
|
const originalText = btns.map((btn) => btn.textContent);
|
|
const pendingText =
|
|
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
|
|
// A clone holds this request open for minutes; without disabling the button a
|
|
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
|
|
btns.forEach((btn) => {
|
|
btn.classList.add('loading');
|
|
btn.textContent = pendingText;
|
|
btn.disabled = true;
|
|
});
|
|
try {
|
|
if (this.caseModalTab === 'case-create') {
|
|
await this.createCase();
|
|
} else if (this.caseModalTab === 'case-clone') {
|
|
await this.cloneCase();
|
|
} else if (this.caseModalTab === 'case-remote') {
|
|
await this.linkRemoteCase();
|
|
} else if (this.caseModalTab === 'case-docker') {
|
|
await this.linkDockerCase();
|
|
} else {
|
|
await this.linkCase();
|
|
}
|
|
} finally {
|
|
btns.forEach((btn, index) => {
|
|
btn.classList.remove('loading');
|
|
btn.disabled = false;
|
|
btn.textContent = originalText[index];
|
|
});
|
|
}
|
|
},
|
|
|
|
async createCase() {
|
|
const name = document.getElementById('newCaseName').value.trim();
|
|
const description = document.getElementById('newCaseDescription').value.trim();
|
|
|
|
if (!name) {
|
|
this.showToast('Please enter a case name', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
|
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(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.closeCreateCaseModal();
|
|
// 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');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create case:', err);
|
|
this.showToast('Failed to create case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// 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();
|
|
|
|
if (!name) {
|
|
this.showToast('Please enter a case name', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!path) {
|
|
this.showToast('Please enter a folder path', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/cases/link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, path })
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.closeCreateCaseModal();
|
|
this.showToast(`Case "${name}" linked to ${path}`, 'success');
|
|
// Reload cases and select the new one
|
|
await this.loadQuickStartCases(name);
|
|
// Save as last used case
|
|
await this.saveLastUsedCase(name);
|
|
} else {
|
|
this.showToast(data.error || 'Failed to link case', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to link case:', err);
|
|
this.showToast('Failed to link case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Clone Repo tab (issue #236)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/** Clear the Clone tab and drop any preflight state. Called from showCreateCaseModal(). */
|
|
_resetCloneForm() {
|
|
const set = (id, value) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = value;
|
|
};
|
|
set('cloneRepoUrl', '');
|
|
set('cloneCaseName', '');
|
|
set('cloneRepoRef', '');
|
|
const shallow = document.getElementById('cloneShallow');
|
|
if (shallow) shallow.checked = false;
|
|
const start = document.getElementById('cloneStartSession');
|
|
if (start) start.checked = false;
|
|
const refs = document.getElementById('cloneRepoRefOptions');
|
|
if (refs) refs.replaceChildren();
|
|
const refHint = document.getElementById('cloneRefHint');
|
|
if (refHint) refHint.textContent = "Leave blank for the repository's default branch.";
|
|
this._cloneNameEdited = false;
|
|
this._clonePreflight = null;
|
|
clearTimeout(this._clonePreflightTimer);
|
|
this._clonePreflightAbort?.abort();
|
|
this._clonePreflightAbort = null;
|
|
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
|
|
// The brain picker mirrors the toolbar run menu: never offer a CLI this box
|
|
// lacks (#201's rule), and preselect whatever Run is currently pointing at.
|
|
const brain = document.getElementById('cloneCaseBrain');
|
|
if (brain) {
|
|
for (const option of brain.options) {
|
|
const cli = option.dataset.cli;
|
|
option.hidden = !!cli && !this.isCliAvailable(cli);
|
|
}
|
|
const current = this.runMode || 'claude';
|
|
brain.value = [...brain.options].some((o) => o.value === current && !o.hidden) ? current : '';
|
|
}
|
|
},
|
|
|
|
_setCloneStatus(message, kind) {
|
|
const el = document.getElementById('cloneRepoStatus');
|
|
if (!el) return;
|
|
el.textContent = message;
|
|
el.className = `form-hint clone-status${kind ? ' clone-status-' + kind : ''}`;
|
|
},
|
|
|
|
/**
|
|
* Best-effort repo name out of a URL, for filling the case name as you type.
|
|
*
|
|
* Deliberately a THIN mirror of `suggestCaseNameFromRepo` (git-clone.ts) rather
|
|
* than a second URL parser: it only ever suggests a name, and the server's parse
|
|
* is the authority on whether the URL is cloneable at all. The preflight reply
|
|
* overwrites whatever this guessed.
|
|
*/
|
|
_repoNameFromUrl(url) {
|
|
const trimmed = (url || '').trim().replace(/\/+$/, '');
|
|
if (!trimmed) return '';
|
|
const segment = trimmed
|
|
.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
|
|
.replace(/^[^@/]*@/, '')
|
|
.split(/[/:]/)
|
|
.filter(Boolean)
|
|
.pop() || '';
|
|
return segment
|
|
.replace(/\.git$/i, '')
|
|
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
.replace(/-{2,}/g, '-')
|
|
.replace(/^[-_]+|[-_]+$/g, '')
|
|
.slice(0, 64);
|
|
},
|
|
|
|
onCloneNameEdited() {
|
|
// Once the user types a name, autofill stops fighting them.
|
|
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
|
|
},
|
|
|
|
onCloneUrlInput() {
|
|
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
|
|
const nameInput = document.getElementById('cloneCaseName');
|
|
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
|
|
clearTimeout(this._clonePreflightTimer);
|
|
this._clonePreflightAbort?.abort();
|
|
this._clonePreflightAbort = null;
|
|
if (!url) {
|
|
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
|
|
return;
|
|
}
|
|
if (this.isCliAvailable('git') === false) {
|
|
this._setCloneStatus('git is not installed on the Codeman host, so cloning is unavailable.', 'err');
|
|
return;
|
|
}
|
|
this._setCloneStatus('Checking the repository…', '');
|
|
this._clonePreflightTimer = setTimeout(() => this._runClonePreflight(url), 450);
|
|
},
|
|
|
|
/**
|
|
* Ask the server to parse the URL and (if it survives) query the remote, so the
|
|
* user learns "private repo" / "typo" / "3 tags" BEFORE waiting on a clone.
|
|
* Stale replies are dropped: only the response for the URL currently in the
|
|
* field is allowed to paint.
|
|
*/
|
|
async _runClonePreflight(url) {
|
|
const controller = new AbortController();
|
|
this._clonePreflightAbort = controller;
|
|
try {
|
|
const res = await fetch('/api/cases/clone-preflight', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ repository: url }),
|
|
signal: controller.signal,
|
|
});
|
|
const env = await res.json();
|
|
if (document.getElementById('cloneRepoUrl')?.value.trim() !== url) return;
|
|
if (!env.success) {
|
|
this._setCloneStatus(env.error || 'Could not check that URL.', 'err');
|
|
return;
|
|
}
|
|
this._applyClonePreflight(env.data, url);
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') return;
|
|
this._setCloneStatus('Could not reach Codeman to check that URL.', 'err');
|
|
}
|
|
},
|
|
|
|
_applyClonePreflight(data, url) {
|
|
this._clonePreflight = data;
|
|
const parse = data?.parse;
|
|
if (!parse?.cloneable) {
|
|
this._setCloneStatus(parse?.message || 'That URL cannot be cloned.', 'err');
|
|
return;
|
|
}
|
|
// The server's suggestion wins over the local guess (it is the same function
|
|
// the case name is validated against), but never over a name the user typed.
|
|
const nameInput = document.getElementById('cloneCaseName');
|
|
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
|
|
|
|
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
|
|
if (data.gitAvailable === false) {
|
|
this._setCloneStatus(`${where}: git is not installed on the Codeman host.`, 'err');
|
|
return;
|
|
}
|
|
const remote = data.remote;
|
|
if (remote && !remote.reachable) {
|
|
this._setCloneStatus(`${where}: ${remote.failure?.message || 'the remote could not be read.'}`, 'err');
|
|
return;
|
|
}
|
|
const refHint = document.getElementById('cloneRefHint');
|
|
const options = document.getElementById('cloneRepoRefOptions');
|
|
if (remote && options) {
|
|
options.replaceChildren();
|
|
for (const ref of [...(remote.branches || []), ...(remote.tags || [])]) {
|
|
const option = document.createElement('option');
|
|
option.value = ref;
|
|
options.appendChild(option);
|
|
}
|
|
if (refHint) {
|
|
const counts = `${remote.branches?.length || 0} branches, ${remote.tags?.length || 0} tags`;
|
|
refHint.textContent = remote.defaultBranch
|
|
? `Blank clones the default branch (${remote.defaultBranch}). ${counts} available.`
|
|
: `Blank clones the default branch. ${counts} available.`;
|
|
}
|
|
}
|
|
const warning = parse.warnings?.[0];
|
|
this._setCloneStatus(warning ? `${where}: ${warning}` : `${where}: ready to clone.`, warning ? 'warn' : 'ok');
|
|
},
|
|
|
|
async cloneCase() {
|
|
const url = document.getElementById('cloneRepoUrl').value.trim();
|
|
const name = document.getElementById('cloneCaseName').value.trim();
|
|
const ref = document.getElementById('cloneRepoRef').value.trim();
|
|
const shallow = !!document.getElementById('cloneShallow')?.checked;
|
|
const brain = document.getElementById('cloneCaseBrain')?.value || '';
|
|
const startSession = !!document.getElementById('cloneStartSession')?.checked;
|
|
|
|
if (!url) {
|
|
this.showToast('Please enter a repository URL', 'error');
|
|
return;
|
|
}
|
|
if (!name) {
|
|
this.showToast('Please enter a case name', 'error');
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
|
return;
|
|
}
|
|
|
|
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
|
|
try {
|
|
const res = await fetch('/api/cases/clone', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
|
|
// on the wire — omit the empty fields instead of sending null.
|
|
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
|
|
});
|
|
const data = await res.json();
|
|
if (!data.success) {
|
|
this._setCloneStatus(data.error || 'Clone failed.', 'err');
|
|
this.showToast(data.error || 'Failed to clone repository', 'error');
|
|
return;
|
|
}
|
|
|
|
// Setting the brain before the tab closes means the Run button is already
|
|
// pointing at the chosen CLI, whether or not a session starts now.
|
|
if (brain) this.setRunMode(brain);
|
|
this.closeCreateCaseModal();
|
|
await this.loadQuickStartCases(name);
|
|
await this.saveLastUsedCase(name);
|
|
this.showToast(`Cloned into case "${name}"`, 'success');
|
|
for (const warning of data.data?.warnings || []) this.showToast(warning, 'warning');
|
|
if (startSession) await this.run();
|
|
} catch (err) {
|
|
// A proxy/idle timeout can kill the request while git keeps going: the
|
|
// case:created broadcast is what makes the case show up regardless.
|
|
console.error('Failed to clone repository:', err);
|
|
this._setCloneStatus(
|
|
`Lost the connection while cloning: ${err.message}. If git finishes, the case still appears in the list.`,
|
|
'warn'
|
|
);
|
|
this.showToast('Clone request interrupted — watch the case list', 'error');
|
|
}
|
|
},
|
|
|
|
openLinkCasePathPicker() {
|
|
const pathInput = document.getElementById('linkCasePath');
|
|
PathPicker.open({
|
|
title: 'Select Existing Project Folder',
|
|
initialPath: pathInput.value.trim(),
|
|
directoriesOnly: true,
|
|
onSelect: (path) => {
|
|
pathInput.value = path;
|
|
const nameInput = document.getElementById('linkCaseName');
|
|
if (!nameInput.value.trim()) {
|
|
const folderName = path.split('/').filter(Boolean).pop() || '';
|
|
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
|
}
|
|
pathInput.focus();
|
|
pathInput.setSelectionRange(path.length, path.length);
|
|
},
|
|
});
|
|
},
|
|
|
|
/** HOST workspace directory — the same picker Link Existing uses. */
|
|
openDockerWorkspacePathPicker() {
|
|
const pathInput = document.getElementById('dockerWorkspacePath');
|
|
PathPicker.open({
|
|
title: 'Select Host Workspace Folder',
|
|
initialPath: pathInput.value.trim(),
|
|
directoriesOnly: true,
|
|
onSelect: (path) => {
|
|
pathInput.value = path;
|
|
const nameInput = document.getElementById('dockerCaseName');
|
|
if (nameInput && !nameInput.value.trim()) {
|
|
const folder = path.split('/').filter(Boolean).pop() || '';
|
|
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Container workdir. Browses INSIDE the container, because for an adopted
|
|
* container nothing is mounted at a matching host path — the host picker would
|
|
* be listing a different filesystem, and typing this field blind is exactly
|
|
* what makes the launch fail with an OCI chdir error.
|
|
*/
|
|
openDockerWorkdirPicker() {
|
|
const pathInput = document.getElementById('dockerAdoptWorkdir');
|
|
const container = document.getElementById('dockerContainerName')?.value.trim();
|
|
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
|
if (!container) {
|
|
this.showToast('Enter the container name first', 'error');
|
|
return;
|
|
}
|
|
PathPicker.open({
|
|
title: `Select Folder Inside ${container}`,
|
|
initialPath: pathInput.value.trim() || '/',
|
|
directoriesOnly: true,
|
|
fetchListing: async (path) => {
|
|
const data = await this._apiJson('/api/docker-cases/browse', {
|
|
method: 'POST',
|
|
body: { hostId, container, path: path || '/' },
|
|
});
|
|
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
|
|
if (data.error) return { success: false, error: data.error };
|
|
// Shape it like the host endpoint: one root, so Up/Location behave.
|
|
return {
|
|
success: true,
|
|
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
|
|
};
|
|
},
|
|
onSelect: (path) => {
|
|
pathInput.value = path;
|
|
},
|
|
});
|
|
},
|
|
|
|
async linkRemoteCase() {
|
|
const name = document.getElementById('remoteCaseName').value.trim();
|
|
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
|
const hostId = document.getElementById('remoteHostId').value.trim();
|
|
const host = document.getElementById('remoteHostAddress').value.trim();
|
|
const username = document.getElementById('remoteHostUsername').value.trim();
|
|
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
|
// COD-107 — port + advanced SSH connection options.
|
|
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
|
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
|
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
|
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
|
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0);
|
|
|
|
if (!name || !remotePath || !hostId || !host || !username) {
|
|
this.showToast('Please complete all required remote fields', '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 (!remotePath.startsWith('/')) {
|
|
this.showToast('Remote path must be absolute', 'error');
|
|
return;
|
|
}
|
|
let port;
|
|
if (portRaw) {
|
|
port = Number(portRaw);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
this.showToast('SSH port must be a number between 1 and 65535', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const hostPayload = {
|
|
id: hostId,
|
|
label: hostId,
|
|
host,
|
|
username,
|
|
...(port ? { port } : {}),
|
|
...(identityFile ? { identityFile } : {}),
|
|
...(socksProxy ? { socksProxy } : {}),
|
|
...(jumpHost ? { jumpHost } : {}),
|
|
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
|
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
|
};
|
|
const hostRes = await fetch('/api/remote-hosts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(hostPayload)
|
|
});
|
|
const hostData = await hostRes.json();
|
|
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
|
throw new Error(hostData.error || 'Failed to save remote host');
|
|
}
|
|
|
|
const caseRes = await fetch('/api/cases/remote-link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, hostId, remotePath })
|
|
});
|
|
const caseData = await caseRes.json();
|
|
if (caseData.success) {
|
|
this.closeCreateCaseModal();
|
|
this.showToast(`Remote case "${name}" linked`, 'success');
|
|
await this.loadQuickStartCases(name);
|
|
await this.saveLastUsedCase(name);
|
|
} else {
|
|
this.showToast(caseData.error || 'Failed to link remote case', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to link remote case:', err);
|
|
this.showToast('Failed to link remote case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reflect the "attach to an existing container" checkbox onto the modal so CSS
|
|
* can swap which half of the Docker panel applies. An attribute rather than
|
|
* per-row inline styles: the panel is rebuilt by nothing, but the create-time
|
|
* rows are a SET (image, network, advanced block) and one attribute keeps them
|
|
* in lockstep with the container-name row.
|
|
*/
|
|
_syncDockerAdoptMode() {
|
|
const modal = document.getElementById('createCaseModal');
|
|
if (!modal) return;
|
|
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
|
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
|
else modal.removeAttribute('data-docker-adopt');
|
|
if (adopting) {
|
|
void this._loadDockerContainerOptions();
|
|
void this._loadDockerCloneOptions();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Fill the container-name `<datalist>`. A native datalist is deliberate: the
|
|
* field must accept a free-typed name (the engine may be remote, or the
|
|
* container may not exist yet when the form is filled), and datalist gives
|
|
* type-to-filter over the suggestions without a custom dropdown.
|
|
*
|
|
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
|
* and an empty list simply leaves the field as plain text input.
|
|
*/
|
|
/**
|
|
* Fill the "Duplicate an Existing Case" picker with the ADOPTED docker cases.
|
|
*
|
|
* One adopted container can back several cases, each pointing at a different
|
|
* directory inside it (classifyAdoptContainerConflict) — but re-typing the
|
|
* container, host and workspace by hand for every directory is exactly the
|
|
* friction that makes the capability go unused. Picking a case here fills those
|
|
* three and leaves only the two fields that MUST differ: the case name and the
|
|
* container workdir.
|
|
*
|
|
* ⚠️ Adopted cases only (`docker.owned === false`). An owned container's
|
|
* lifecycle belongs to its one case — a second case on it would be destroyed
|
|
* out from under itself by that case's recreate or delete — and the server
|
|
* refuses it, so offering it here would only produce a confusing error.
|
|
*/
|
|
async _loadDockerCloneOptions() {
|
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
|
const row = document.getElementById('dockerAdoptCloneRow');
|
|
if (!select || !row) return;
|
|
let cases = [];
|
|
try {
|
|
const res = await fetch('/api/cases');
|
|
const data = await res.json();
|
|
cases = (Array.isArray(data) ? data : data?.data || []).filter(
|
|
(c) => c?.docker && c.docker.owned === false
|
|
);
|
|
} catch {
|
|
cases = [];
|
|
}
|
|
select.textContent = '';
|
|
const blank = document.createElement('option');
|
|
blank.value = '';
|
|
blank.textContent = 'Start from scratch';
|
|
select.appendChild(blank);
|
|
for (const c of cases) {
|
|
const option = document.createElement('option');
|
|
option.value = c.name;
|
|
// Server-supplied strings: textContent, never markup.
|
|
option.textContent = `${c.name} — ${c.docker.container}:${c.docker.containerWorkdir || c.docker.path}`;
|
|
option.dataset.container = c.docker.container;
|
|
option.dataset.hostId = c.docker.hostId;
|
|
option.dataset.path = c.docker.path;
|
|
option.dataset.workdir = c.docker.containerWorkdir || c.docker.path;
|
|
select.appendChild(option);
|
|
}
|
|
// Nothing to duplicate yet: an empty picker is noise on the first adoption.
|
|
row.hidden = cases.length === 0;
|
|
},
|
|
|
|
/**
|
|
* Apply the picked case: carry over what STAYS the same, clear what must not.
|
|
*
|
|
* The two cleared fields are the point of the feature — a duplicate that kept
|
|
* the original's name would be rejected as an existing case, and one that kept
|
|
* its container workdir would be rejected as an exact twin (both by the server,
|
|
* with a clear message, but a form that pre-fills a value it knows will be
|
|
* refused is just a trap).
|
|
*/
|
|
applyDockerCloneSource() {
|
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
|
const option = select?.selectedOptions?.[0];
|
|
if (!option || !option.value) return;
|
|
const set = (id, value) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = value || '';
|
|
};
|
|
set('dockerContainerName', option.dataset.container);
|
|
set('dockerHostId', option.dataset.hostId);
|
|
set('dockerWorkspacePath', option.dataset.path);
|
|
// Pre-filled, NOT cleared: these two must differ from the source, but editing
|
|
// `/srv/app/api` into `/srv/app/web` beats retyping a long path, and the same
|
|
// goes for the name. What keeps a duplicate from being submitted unchanged is
|
|
// the guard below (dockerCloneGuard), which is a better trade than an empty
|
|
// field: the form stays a starting point instead of a blank form with three
|
|
// fields mysteriously filled in.
|
|
set('dockerCaseName', option.value);
|
|
set('dockerAdoptWorkdir', option.dataset.workdir);
|
|
// Remembered so the guard can tell "unchanged" from "happens to look similar".
|
|
select.dataset.appliedName = option.value;
|
|
select.dataset.appliedWorkdir = option.dataset.workdir || '';
|
|
const workdir = document.getElementById('dockerAdoptWorkdir');
|
|
workdir?.focus();
|
|
// Caret at the end: the tail is the part that changes.
|
|
if (workdir) workdir.setSelectionRange(workdir.value.length, workdir.value.length);
|
|
},
|
|
|
|
/**
|
|
* Refuse a duplicate that still carries the source case's name or directory.
|
|
*
|
|
* Both are pre-filled so they can be EDITED, which means both can also be left
|
|
* alone by accident. The server refuses either (an existing case name, or an
|
|
* exact same-container-same-directory twin) with a clear message, but a
|
|
* round-trip to be told "you forgot to change the field you were looking at" is
|
|
* worse than saying so here, next to the field, before anything is sent.
|
|
*
|
|
* Returns the offending element, or null when the form is fine.
|
|
*/
|
|
dockerCloneGuard() {
|
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
|
if (!select || !select.value) return null;
|
|
const name = document.getElementById('dockerCaseName');
|
|
const workdir = document.getElementById('dockerAdoptWorkdir');
|
|
if (name && name.value.trim() === (select.dataset.appliedName || '')) {
|
|
return { el: name, message: `"${name.value.trim()}" is the case you copied from — give this one a new name.` };
|
|
}
|
|
if (workdir && workdir.value.trim() === (select.dataset.appliedWorkdir || '')) {
|
|
return {
|
|
el: workdir,
|
|
message: 'Same container and same directory as the case you copied from — point this one at another directory.',
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async _loadDockerContainerOptions() {
|
|
const list = document.getElementById('dockerContainerList');
|
|
if (!list) return;
|
|
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
|
if (list.dataset.loadedFor === hostId) return; // one fetch per host per open
|
|
const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`);
|
|
const containers = data?.containers || [];
|
|
list.textContent = '';
|
|
for (const c of containers) {
|
|
const option = document.createElement('option');
|
|
option.value = c.name;
|
|
// Engine-supplied strings: set as text, never as markup.
|
|
option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`;
|
|
list.appendChild(option);
|
|
}
|
|
list.dataset.loadedFor = hostId;
|
|
},
|
|
|
|
/**
|
|
* Cross-link from the Create New tab's "Run in an isolated Docker container"
|
|
* row. Adoption lives on the Docker tab, but the place users actually look for
|
|
* anything container-shaped is that checkbox, so this jumps them there with the
|
|
* toggle already on rather than leaving the feature undiscoverable.
|
|
*/
|
|
jumpToDockerAdopt() {
|
|
this.switchCaseModalTab('case-docker');
|
|
const toggle = document.getElementById('dockerAdoptExisting');
|
|
if (toggle) toggle.checked = true;
|
|
this._syncDockerAdoptMode();
|
|
document.getElementById('dockerContainerName')?.focus();
|
|
},
|
|
|
|
/**
|
|
* Read-only preflight against an existing container. It links nothing, so the
|
|
* user can find out "not running" / "no tmux" / "codex present, claude missing"
|
|
* before committing to a case name — the same reason the server refuses at link
|
|
* time rather than at session launch.
|
|
*/
|
|
async _dockerAdoptPreflight() {
|
|
const statusEl = document.getElementById('dockerLinkStatus');
|
|
const container = document.getElementById('dockerContainerName')?.value.trim();
|
|
const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim();
|
|
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
|
if (!container) {
|
|
if (statusEl) statusEl.textContent = 'Enter a container name first.';
|
|
return;
|
|
}
|
|
if (statusEl) statusEl.textContent = 'Inspecting container...';
|
|
// _apiJson folds every failure to null, and a preflight's whole value is the
|
|
// reason it failed, so the envelope is unwrapped by hand here.
|
|
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
|
method: 'POST',
|
|
body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) },
|
|
});
|
|
if (!statusEl) return;
|
|
if (!probe) {
|
|
statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.';
|
|
return;
|
|
}
|
|
if (!probe.ok) {
|
|
statusEl.textContent = probe.error || 'Container is not adoptable.';
|
|
return;
|
|
}
|
|
const modes = (probe.availableModes || []).filter((m) => m !== 'shell');
|
|
statusEl.textContent = modes.length
|
|
? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.`
|
|
: `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`;
|
|
},
|
|
|
|
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 adopting = !!document.getElementById('dockerAdoptExisting')?.checked;
|
|
const container = document.getElementById('dockerContainerName')?.value.trim() || '';
|
|
const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || '';
|
|
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;
|
|
}
|
|
if (adopting && !container) {
|
|
this.showToast('Enter the name of the running container to attach to', 'error');
|
|
return;
|
|
}
|
|
// A duplicate that still carries the source's name or directory: say so here,
|
|
// beside the field, rather than sending a request certain to come back refused.
|
|
const cloneIssue = adopting ? this.dockerCloneGuard() : null;
|
|
if (cloneIssue) {
|
|
this.showToast(cloneIssue.message, 'error');
|
|
const statusEl = document.getElementById('dockerLinkStatus');
|
|
if (statusEl) statusEl.textContent = cloneIssue.message;
|
|
cloneIssue.el.focus();
|
|
cloneIssue.el.select?.();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (statusEl) {
|
|
statusEl.textContent = adopting ? 'Inspecting the existing container...' : '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');
|
|
|
|
// Adoption reuses this whole flow and differs only in the final call: a
|
|
// different endpoint (which never creates a container) plus the container
|
|
// name. The host upsert above still applies — it is what resolves the
|
|
// engine/context/daemon for the `docker exec`; its create-time fields are
|
|
// simply never read for an adopted case.
|
|
const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(
|
|
adopting
|
|
? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) }
|
|
: { 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)' : '';
|
|
const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell');
|
|
const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : '';
|
|
this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, '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="${CodemanBase.url(`/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
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/** Read the remote-host fields from the remote-case form into a host payload. */
|
|
_readRemoteHostFromForm() {
|
|
const hostId = document.getElementById('remoteHostId').value.trim();
|
|
const host = document.getElementById('remoteHostAddress').value.trim();
|
|
const username = document.getElementById('remoteHostUsername').value.trim();
|
|
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
|
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
|
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
|
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
|
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
|
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0);
|
|
let port;
|
|
if (portRaw) {
|
|
const n = Number(portRaw);
|
|
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
|
|
}
|
|
return {
|
|
id: hostId,
|
|
label: hostId,
|
|
host,
|
|
username,
|
|
...(port ? { port } : {}),
|
|
...(identityFile ? { identityFile } : {}),
|
|
...(socksProxy ? { socksProxy } : {}),
|
|
...(jumpHost ? { jumpHost } : {}),
|
|
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
|
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Explicit Discover action (Decision A — never auto-runs on host select).
|
|
* Saves the host config (idempotent), then queries the host for `codeman-*`
|
|
* tmux sessions it didn't create and renders an Attach action per session.
|
|
*/
|
|
async discoverRemoteSessions() {
|
|
const results = document.getElementById('remoteDiscoverResults');
|
|
const btn = document.getElementById('remoteDiscoverBtn');
|
|
const hostPayload = this._readRemoteHostFromForm();
|
|
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
|
|
this.showToast('Fill in Host ID, address, and username first', 'error');
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
|
|
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
|
|
return;
|
|
}
|
|
if (btn) btn.disabled = true;
|
|
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
|
|
try {
|
|
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
|
|
const hostRes = await fetch('/api/remote-hosts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(hostPayload)
|
|
});
|
|
const hostData = await hostRes.json();
|
|
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
|
throw new Error(hostData.error || 'Failed to save remote host');
|
|
}
|
|
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
|
|
const data = await res.json();
|
|
if (!data.success) throw new Error(data.error || 'Discovery failed');
|
|
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
|
|
} catch (err) {
|
|
console.error('Discover remote sessions failed:', err);
|
|
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
},
|
|
|
|
/** Render the discovered remote sessions with an Attach action each. */
|
|
_renderDiscoveredSessions(hostId, sessions) {
|
|
const results = document.getElementById('remoteDiscoverResults');
|
|
if (!results) return;
|
|
if (!sessions.length) {
|
|
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
|
|
return;
|
|
}
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const rows = sessions.map(s => {
|
|
const ageSecs = Math.max(0, now - (s.created || 0));
|
|
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
|
|
// COD-106 — show "shared · N clients" when more than one client is attached
|
|
// (genuinely collaborative), else a plain "attached" badge for a single client.
|
|
const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
|
|
const attachedBadge =
|
|
clients > 1
|
|
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
|
|
: clients === 1
|
|
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
|
|
: '';
|
|
return `
|
|
<div class="remote-discover-item">
|
|
<div class="remote-discover-info">
|
|
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
|
|
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
|
|
</div>
|
|
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
|
|
</div>`;
|
|
}).join('');
|
|
results.innerHTML = rows;
|
|
},
|
|
|
|
/**
|
|
* Create a NON-owned session that attaches to a discovered remote tmux session.
|
|
* Closing this tab detaches — it never kills the remote session.
|
|
*/
|
|
async attachDiscoveredSession(hostId, remoteSessionName) {
|
|
try {
|
|
const createRes = await fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mode: 'shell',
|
|
name: remoteSessionName,
|
|
attachRemoteSession: { hostId, remoteSessionName },
|
|
})
|
|
});
|
|
const createData = await createRes.json();
|
|
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
|
|
const id = createData.data.session.id;
|
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
|
const dims = this.getTerminalDimensions();
|
|
if (dims) {
|
|
await fetch(`/api/sessions/${id}/resize`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(dims)
|
|
});
|
|
}
|
|
this.closeCreateCaseModal();
|
|
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
|
|
this.activeSessionId = id;
|
|
await this.selectSession(id);
|
|
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
|
|
} catch (err) {
|
|
console.error('Attach discovered session failed:', err);
|
|
this.showToast('Failed to attach: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Case Management (reorder + delete)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
renderCaseManageList() {
|
|
const container = document.getElementById('caseManageList');
|
|
const cases = this.cases || [];
|
|
if (cases.length === 0) {
|
|
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
|
|
return;
|
|
}
|
|
|
|
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
|
// A long orchestration leaves one scratch directory per worker behind, so they get
|
|
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
|
const agentCases = cases.filter(c => c.agentCreated);
|
|
let html = agentCases.length > 0
|
|
? `<div class="case-manage-agent-bar">
|
|
<span class="case-manage-agent-count">${agentCases.length} case${agentCases.length === 1 ? '' : 's'} created by agent workers</span>
|
|
<button class="case-manage-btn case-manage-btn-cleanup" onclick="app.cleanupAgentCases()"
|
|
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
|
</div>`
|
|
: '';
|
|
cases.forEach((c, idx) => {
|
|
const isFirst = idx === 0;
|
|
const isLast = idx === cases.length - 1;
|
|
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
|
// case path on a Linux host rendered in full, unabbreviated.
|
|
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
|
const agentTitle = c.agentCreated
|
|
? `Created by an agent worker${c.agentCreated.parentSessionName ? ` from ${c.agentCreated.parentSessionName}` : ''}` +
|
|
` (${c.agentCreated.createdBy})${c.agentCreated.createdAt ? ` on ${new Date(c.agentCreated.createdAt).toLocaleString()}` : ''}`
|
|
: '';
|
|
html += `
|
|
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
|
<div class="case-manage-info">
|
|
<span class="case-manage-name">${escapeHtml(c.name)}${
|
|
c.agentCreated ? `<span class="case-manage-tag-agent" title="${escapeHtml(agentTitle)}" data-i18n-skip>agent</span>` : ''
|
|
}</span>
|
|
<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))})"
|
|
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
|
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
|
title="Delete case">✕</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
async moveCaseUp(name) {
|
|
const cases = this.cases || [];
|
|
const idx = cases.findIndex(c => c.name === name);
|
|
if (idx <= 0) return;
|
|
// Swap positions (immutable)
|
|
const reordered = [...cases];
|
|
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
|
|
this.cases = reordered;
|
|
this.renderCaseManageList();
|
|
await this.saveCaseOrder(reordered.map(c => c.name));
|
|
},
|
|
|
|
async moveCaseDown(name) {
|
|
const cases = this.cases || [];
|
|
const idx = cases.findIndex(c => c.name === name);
|
|
if (idx < 0 || idx >= cases.length - 1) return;
|
|
const reordered = [...cases];
|
|
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
|
|
this.cases = reordered;
|
|
this.renderCaseManageList();
|
|
await this.saveCaseOrder(reordered.map(c => c.name));
|
|
},
|
|
|
|
async deleteCase(name) {
|
|
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
|
// Remove from current list and refresh
|
|
this.cases = (this.cases || []).filter(c => c.name !== name);
|
|
this.renderCaseManageList();
|
|
// Refresh the dropdown
|
|
const select = document.getElementById('quickStartCase');
|
|
const currentCase = select.value;
|
|
if (currentCase === name) {
|
|
// Blur the native picker before reload so it doesn't show the stale value
|
|
select.blur?.();
|
|
}
|
|
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
|
if (currentCase === name) {
|
|
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
|
}
|
|
} else {
|
|
this.showToast(data.error || 'Failed to delete case', 'error');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Failed to delete case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Review-then-delete the scratch cases agent workers left behind.
|
|
*
|
|
* ⚠️ Never silently bulk-deletes: the confirm names every directory, and a case a
|
|
* LIVE session is still working in is excluded outright rather than confirmed away
|
|
* (`inUse` from the server, which knows every session's working directory). Removal
|
|
* reuses `DELETE /api/cases/:name` one name at a time, so there is no second
|
|
* recursive-delete path to keep in step with the first.
|
|
*/
|
|
async cleanupAgentCases() {
|
|
let agentCases;
|
|
try {
|
|
const res = await fetch('/api/cases/agent-created');
|
|
const body = await res.json();
|
|
if (!body.success) {
|
|
this.showToast(body.error || 'Failed to list agent cases', 'error');
|
|
return;
|
|
}
|
|
agentCases = body.data.cases || [];
|
|
} catch (err) {
|
|
this.showToast('Failed to list agent cases: ' + err.message, 'error');
|
|
return;
|
|
}
|
|
|
|
const busy = agentCases.filter(c => c.inUse);
|
|
const removable = agentCases.filter(c => !c.inUse);
|
|
if (removable.length === 0) {
|
|
this.showToast(
|
|
busy.length > 0
|
|
? `All ${busy.length} agent case(s) are still in use by a running session`
|
|
: 'No agent-created cases to clean up',
|
|
'info'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const names = removable.map(c => ` ${c.name}`).join('\n');
|
|
const busyNote = busy.length > 0 ? `\n\nSkipping ${busy.length} case(s) still in use by a running session.` : '';
|
|
if (!confirm(`Permanently delete ${removable.length} agent-created case folder(s) and everything in them?\n\n${names}${busyNote}`)) {
|
|
return;
|
|
}
|
|
|
|
let deleted = 0;
|
|
const failed = [];
|
|
for (const item of removable) {
|
|
try {
|
|
const res = await fetch(`/api/cases/${encodeURIComponent(item.name)}`, { method: 'DELETE' });
|
|
const body = await res.json();
|
|
if (body.success) deleted++;
|
|
else failed.push(item.name);
|
|
} catch {
|
|
failed.push(item.name);
|
|
}
|
|
}
|
|
|
|
this.showToast(
|
|
failed.length === 0
|
|
? `Deleted ${deleted} agent case(s)`
|
|
: `Deleted ${deleted}, failed: ${failed.join(', ')}`,
|
|
failed.length === 0 ? 'success' : 'error'
|
|
);
|
|
|
|
// Refresh the picker (its selected case may be one we just deleted) and the list.
|
|
const select = document.getElementById('quickStartCase');
|
|
const currentCase = select?.value;
|
|
const currentDeleted = removable.some(c => c.name === currentCase);
|
|
if (currentDeleted) select?.blur?.();
|
|
await this.loadQuickStartCases(currentDeleted ? null : currentCase);
|
|
if (currentDeleted) {
|
|
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
|
}
|
|
this.renderCaseManageList();
|
|
},
|
|
|
|
async saveCaseOrder(order) {
|
|
try {
|
|
await fetch('/api/cases/order', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ order })
|
|
});
|
|
// Refresh dropdown to reflect new order
|
|
const select = document.getElementById('quickStartCase');
|
|
const currentCase = select.value;
|
|
await this.loadQuickStartCases(currentCase);
|
|
} catch (err) {
|
|
this.showToast('Failed to save case order: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Mobile Case Picker
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
showMobileCasePicker() {
|
|
const modal = document.getElementById('mobileCasePickerModal');
|
|
const listContainer = document.getElementById('mobileCaseList');
|
|
const select = document.getElementById('quickStartCase');
|
|
const currentCase = select.value;
|
|
|
|
// Build case list HTML
|
|
let html = '';
|
|
const allCases = this.getCasePickerOptions();
|
|
|
|
for (const c of allCases) {
|
|
const isSelected = c.name === currentCase;
|
|
html += `
|
|
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
|
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
|
<span class="mobile-case-item-icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
</span>
|
|
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
|
|
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</span>
|
|
<span class="mobile-case-item-check">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
listContainer.innerHTML = html;
|
|
modal.classList.add('active');
|
|
},
|
|
|
|
closeMobileCasePicker() {
|
|
document.getElementById('mobileCasePickerModal').classList.remove('active');
|
|
},
|
|
|
|
selectMobileCase(caseName) {
|
|
// Update the desktop select (source of truth)
|
|
const select = document.getElementById('quickStartCase');
|
|
select.value = caseName;
|
|
|
|
// Update mobile button label
|
|
this.updateMobileCaseLabel(caseName);
|
|
|
|
// Update directory display
|
|
this.updateDirDisplayForCase(caseName);
|
|
|
|
// Save as last used
|
|
this.saveLastUsedCase(caseName);
|
|
|
|
// Close the picker
|
|
this.closeMobileCasePicker();
|
|
|
|
this.showToast(`Selected: ${caseName}`, 'success');
|
|
},
|
|
|
|
updateMobileCaseLabel(caseName) {
|
|
const label = document.getElementById('mobileCaseName');
|
|
if (label) {
|
|
// Let CSS handle truncation via text-overflow: ellipsis
|
|
label.textContent = caseName;
|
|
}
|
|
},
|
|
|
|
async deleteCaseMobile(name) {
|
|
if (!confirm(`Delete case "${name}"?`)) return;
|
|
try {
|
|
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
|
this.cases = (this.cases || []).filter(c => c.name !== name);
|
|
// Refresh mobile picker and dropdown
|
|
this.closeMobileCasePicker();
|
|
await this.loadQuickStartCases();
|
|
} else {
|
|
this.showToast(data.error || 'Failed to delete case', 'error');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Failed to delete case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
showCreateCaseFromMobile() {
|
|
// Close mobile picker first
|
|
this.closeMobileCasePicker();
|
|
// Open the create case modal with slide-up animation
|
|
this.showCreateCaseModal();
|
|
const modal = document.getElementById('createCaseModal');
|
|
modal.classList.add('from-mobile');
|
|
// Remove animation class after it plays
|
|
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
return this._runMode || 'claude';
|
|
},
|
|
set(mode) {
|
|
this._runMode =
|
|
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
|
? mode
|
|
: 'claude';
|
|
},
|
|
});
|