mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
Three things, all on the same surface. **Tighter.** The shell drops to 760x620 (was 840x700) and the density comes down with it: rail 176px, doc padding 15px, row padding 5px 10px, group gaps 3px, section head 0.88rem, row label 0.76rem, description 0.645rem. The model cards were the biggest block in the document and shrink the most (6px 8px padding, 0.72rem name). The toggle switches keep their size on purpose - only the space around them was the problem. **Checkboxes stay checkboxes.** The respawn cycle steps go back to real checkboxes in a row card (`.set-checks` / `.set-check`) rather than the chips they briefly became: they are numbered steps of one sequence, not a set of independent tags, and chips read as the latter. **Add Case joins the surface.** Same shell, rail and sections; its rail switches panels like Session Options'. The six panels keep their legacy `.form-row` markup - every id in them is read back by session-ui.js, so restructuring the forms would be a lot of risk for no visual gain. Instead an adapter block scoped to `#createCaseModal .set-doc` maps the old primitives onto the look: a form row paints as a row card, its label as a row label, its `.form-hint` as a row description, `<details class="advanced-options">` as a collapsed group head. `.form-row` everywhere else is untouched. With that, `.modal-tabs` / `.modal-tab-btn` / `.modal-tab-content` have no users left, so their CSS is deleted from both stylesheets and the guard in test/app-settings-structure.test.ts flips from "the settings modal must not steal these shared classes" to "nothing uses them any more" - a reappearance now means a modal drifted back off the shared surface.
2925 lines
121 KiB
JavaScript
2925 lines
121 KiB
JavaScript
/**
|
|
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity),
|
|
* 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);
|
|
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 === '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);
|
|
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 is in this list even though #201 predates it — it is a run mode
|
|
* like the rest, and `agy` is the LEAST likely of the five to be installed.
|
|
*/
|
|
_refreshRunModeAvailability(menu) {
|
|
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
|
|
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
|
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
|
}
|
|
},
|
|
|
|
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 });
|
|
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'run-mode-option';
|
|
btn.title = s.workingDir;
|
|
btn.dataset.sessionId = s.sessionId;
|
|
btn.dataset.workingDir = s.workingDir;
|
|
|
|
const dirSpan = document.createElement('span');
|
|
dirSpan.className = 'hist-dir';
|
|
dirSpan.textContent = shortDir;
|
|
|
|
const metaSpan = document.createElement('span');
|
|
metaSpan.className = 'hist-meta';
|
|
metaSpan.textContent = timeStr;
|
|
|
|
btn.append(dirSpan, metaSpan);
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
|
});
|
|
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 === '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 } : {}),
|
|
// Plan-usage statusLine exporter (App Settings → Display). The server
|
|
// ADDS our exporter on create when true; when false it intentionally
|
|
// leaves any existing exporter in place (a per-repo settings.local.json
|
|
// is shared by sibling sessions, so create-with-false must not yank it
|
|
// — see the comment in session-routes create). Disabling the setting
|
|
// removes it via the App Settings toggle path (system-routes), not here.
|
|
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
|
|
})
|
|
}).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 res = await fetch('/api/quick-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
caseName,
|
|
mode: 'opencode',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
openCodeConfig: { autoAllowTools: true },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
}),
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
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 res = await fetch('/api/quick-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
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 } : {}),
|
|
}),
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
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 res = await fetch('/api/quick-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
caseName,
|
|
mode: 'gemini',
|
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
|
...(isRemote ? {} : {
|
|
geminiConfig: { approvalMode: 'yolo' },
|
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
}),
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
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);
|
|
}
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Options Modal
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
openSessionOptions(sessionId) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
this.editingSessionId = 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';
|
|
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';
|
|
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();
|
|
},
|
|
|
|
async saveSessionName() {
|
|
if (!this.editingSessionId) return;
|
|
const session = this.sessions.get(this.editingSessionId);
|
|
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;
|
|
}
|
|
try {
|
|
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
|
} catch (err) {
|
|
this.showToast('Failed to save session name: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
const currentName = this.getSessionName(session);
|
|
const parsed = parseSessionPrefix(session.name);
|
|
const originalContent = tabName.textContent;
|
|
// 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.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';
|
|
input.style.cssText = 'width: 80px; 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();
|
|
|
|
const finishRename = async ({ commit }) => {
|
|
if (!this._inlineRenameActive) return; // prevent double-fire
|
|
this._inlineRenameActive = false;
|
|
this._activeRename = null;
|
|
|
|
// 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) {
|
|
this.renderSessionTabs();
|
|
return;
|
|
}
|
|
|
|
const suffix = input.value.trim();
|
|
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
|
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) {
|
|
try {
|
|
await fetch(`/api/sessions/${sessionId}/name`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: fullName })
|
|
});
|
|
} catch (err) {
|
|
tabName.textContent = originalContent;
|
|
this.showToast('Failed to rename', 'error');
|
|
}
|
|
}
|
|
// Re-render tabs to restore full tab structure
|
|
this.renderSessionTabs();
|
|
};
|
|
|
|
// Register only after the input is wired so a throw above can't strand state.
|
|
this._activeRename = {
|
|
sessionId,
|
|
cancel: () => finishRename({ commit: false }),
|
|
};
|
|
|
|
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') {
|
|
input.value = '';
|
|
input.blur();
|
|
}
|
|
});
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 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);
|
|
});
|
|
// 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 <= 430) {
|
|
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 button (hide for manage tab)
|
|
const submitBtn = document.getElementById('caseModalSubmit');
|
|
if (tabName === 'case-manage') {
|
|
submitBtn.style.display = 'none';
|
|
this.renderCaseManageList();
|
|
this.refreshDockerExports();
|
|
} else {
|
|
submitBtn.style.display = '';
|
|
submitBtn.textContent =
|
|
tabName === 'case-create'
|
|
? 'Create'
|
|
: tabName === 'case-clone'
|
|
? 'Clone'
|
|
: tabName === 'case-remote'
|
|
? 'Link Remote'
|
|
: tabName === 'case-docker'
|
|
? 'Link Docker'
|
|
: 'Link';
|
|
}
|
|
// 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() {
|
|
const btn = document.getElementById('caseModalSubmit');
|
|
const originalText = btn.textContent;
|
|
btn.classList.add('loading');
|
|
btn.textContent =
|
|
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).
|
|
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 {
|
|
btn.classList.remove('loading');
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
},
|
|
|
|
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);
|
|
},
|
|
});
|
|
},
|
|
|
|
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');
|
|
}
|
|
},
|
|
|
|
async linkDockerCase() {
|
|
const name = document.getElementById('dockerCaseName').value.trim();
|
|
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
|
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
|
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
|
const network = document.getElementById('dockerNetwork').value;
|
|
const memory = document.getElementById('dockerMemory').value.trim();
|
|
const cpus = document.getElementById('dockerCpus').value.trim();
|
|
const mountCredentials = document.getElementById('dockerMountCredentials').checked;
|
|
const resumeOnStart = document.getElementById('dockerResumeOnStart').checked;
|
|
const statusEl = document.getElementById('dockerLinkStatus');
|
|
|
|
if (!name || !hostWorkspacePath) {
|
|
this.showToast('Please enter a case name and workspace path', 'error');
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
|
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
|
return;
|
|
}
|
|
if (!hostWorkspacePath.startsWith('/')) {
|
|
this.showToast('Workspace path must be absolute', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
|
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
|
const resources = {};
|
|
if (memory) resources.memory = memory;
|
|
if (cpus) resources.cpus = cpus;
|
|
const hostPayload = {
|
|
id: hostId,
|
|
label: hostId,
|
|
image,
|
|
network,
|
|
mountCredentials,
|
|
resumeOnStart,
|
|
...(Object.keys(resources).length ? { resources } : {}),
|
|
};
|
|
// PUT (update-or-create) so re-linking with the same host id refreshes its settings.
|
|
let hostRes = await fetch('/api/docker-hosts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(hostPayload),
|
|
});
|
|
let hostData = await hostRes.json();
|
|
if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') {
|
|
hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(hostPayload),
|
|
});
|
|
hostData = await hostRes.json();
|
|
}
|
|
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
|
|
|
const caseRes = await fetch('/api/cases/docker-link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
|
});
|
|
const caseData = await caseRes.json();
|
|
if (caseData.success) {
|
|
this.closeCreateCaseModal();
|
|
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
|
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
|
await this.loadQuickStartCases(name);
|
|
await this.saveLastUsedCase(name);
|
|
} else {
|
|
if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case';
|
|
this.showToast(caseData.error || 'Failed to link docker case', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to link docker case:', err);
|
|
if (statusEl) statusEl.textContent = err.message;
|
|
this.showToast('Failed to link docker case: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Docker export / import UI
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
async refreshDockerExports() {
|
|
const listEl = document.getElementById('dockerExportsList');
|
|
if (!listEl) return;
|
|
try {
|
|
const res = await fetch('/api/docker-exports');
|
|
const data = await res.json();
|
|
const exports = data?.data?.exports || [];
|
|
if (exports.length === 0) {
|
|
listEl.innerHTML = '<span class="form-hint">No exports yet. Export a docker case from its tab.</span>';
|
|
return;
|
|
}
|
|
listEl.innerHTML = exports
|
|
.map(e => {
|
|
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
|
// escapeHtml is the free function from constants.js (never a method on `this`)
|
|
const nm = escapeHtml(e.name);
|
|
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
|
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
|
<span style="flex-shrink:0;">
|
|
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
|
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
|
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
|
</span>
|
|
</div>`;
|
|
})
|
|
.join('');
|
|
} catch (err) {
|
|
listEl.innerHTML = `<span class="form-hint">Failed to load exports: ${err.message}</span>`;
|
|
}
|
|
},
|
|
|
|
async exportDockerCaseBundle(caseName, mode = 'full') {
|
|
try {
|
|
const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info');
|
|
} else {
|
|
this.showToast(data.error || 'Export failed', 'error');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Export failed: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
async importDockerBundle(bundle) {
|
|
const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported');
|
|
if (!newCaseName) return;
|
|
const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', '');
|
|
if (!destWorkspacePath) return;
|
|
try {
|
|
const res = await fetch('/api/docker-cases/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.showToast(`Imported as "${newCaseName}"`, 'success');
|
|
await this.loadQuickStartCases(newCaseName);
|
|
} else {
|
|
this.showToast(data.error || 'Import failed', 'error');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Import failed: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
async deleteDockerExport(filename) {
|
|
if (!confirm(`Delete export bundle "${filename}"?`)) return;
|
|
try {
|
|
const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.showToast('Export deleted', 'success');
|
|
this.refreshDockerExports();
|
|
} else {
|
|
this.showToast(data.error || 'Delete failed', 'error');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Delete failed: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// COD-105 — Discover + attach existing remote tmux sessions
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/** 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;
|
|
}
|
|
|
|
let html = '';
|
|
cases.forEach((c, idx) => {
|
|
const isFirst = idx === 0;
|
|
const isLast = idx === cases.length - 1;
|
|
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
|
|
html += `
|
|
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
|
<div class="case-manage-info">
|
|
<span class="case-manage-name">${escapeHtml(c.name)}</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');
|
|
}
|
|
},
|
|
|
|
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 === 'claude'
|
|
? mode
|
|
: 'claude';
|
|
},
|
|
});
|