/** * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi), * 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-' 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 => ``) .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 = '
No cases match
'; 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 ` `; }) .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 === 'pi') { return await this.runPi(); } 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 and Pi are in this list even though #201 predates them — they are * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) { 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 = '
Loading...
'; try { const display = await this._fetchHistorySessions(10); if (display.length === 0) { container.innerHTML = '
No history
'; return; } // Build items using DOM API for reliable mobile touch handling container.replaceChildren(); for (const s of display) { const date = new Date(s.lastModified); const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); // Shared helper, not a local regex: the copy that used to live here // matched `/home//` only, so on macOS (`/Users//`) nothing was // stripped and every row spent its first ~19 characters on an identical // prefix — with the tail ellipsized, all rows rendered as // `/Users/jordanryan/co…` and became indistinguishable (#273). const shortDir = this._shortenHomePath(s.workingDir); // Lead with the folder that identifies the row; the parent path trails and // is what gets truncated. Truncation must never eat the identity. const lastSlash = shortDir.lastIndexOf('/'); const leafName = lastSlash === -1 ? shortDir : shortDir.slice(lastSlash + 1); // `/.claude/worktrees` in the parent path is pure noise once the pill // says which worktree it is — drop it so the repo stays visible instead. const parentDir = (lastSlash === -1 ? '' : shortDir.slice(0, lastSlash)).replace(/\/\.claude\/worktrees$/, ''); const btn = document.createElement('button'); btn.className = 'run-mode-option run-mode-hist-row'; btn.title = s.workingDir; btn.dataset.sessionId = s.sessionId; btn.dataset.workingDir = s.workingDir; const nameSpan = document.createElement('span'); nameSpan.className = 'hist-name'; nameSpan.textContent = leafName; const parts = [nameSpan]; // Worktree pill, same data the session rows use (#266). A worktree's // directory basename is often just the worktree name, so without this two // worktrees of one repo still read alike. const wt = this._worktreeLabel ? this._worktreeLabel(s) : ''; if (wt) { const wtSpan = document.createElement('span'); wtSpan.className = 'hist-wt'; wtSpan.textContent = wt; parts.push(wtSpan); } if (parentDir) { const dirSpan = document.createElement('span'); dirSpan.className = 'hist-dir'; dirSpan.textContent = parentDir; parts.push(dirSpan); } const metaSpan = document.createElement('span'); metaSpan.className = 'hist-meta'; metaSpan.textContent = timeStr; parts.push(metaSpan); btn.append(...parts); btn.addEventListener('click', (e) => { e.stopPropagation(); this.resumeHistorySession(s.sessionId, s.workingDir, s.name); }); container.appendChild(btn); } } catch (err) { container.innerHTML = '
Failed to load
'; } }, _applyRunMode() { const mode = this.runMode; const runBtn = document.getElementById('runBtn'); const gearBtn = runBtn?.nextElementSibling; const label = document.getElementById('runBtnLabel'); if (runBtn) { runBtn.className = `btn-toolbar btn-run mode-${mode}`; } if (gearBtn) { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === '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 index for a case's session tabs (e.g. w1-, // w2- for agents, s1- 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- convention as local // sessions (quick-start would otherwise auto-generate codeman-). 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); } }, /** * Launch a Pi (pi.dev) session. * * Deliberately sends NO piConfig: pi has no permission prompts, so there is no * bypass to opt into, and project trust is pi's own `defaultProjectTrust` * decision (an interactive prompt the user answers in the terminal). Sending * `approveProjectTrust: true` here would silently opt every browser-launched pi * session into executing repo-supplied TypeScript. */ async runPi() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote/docker cases run pi on the OTHER side — skip the local status probe and the // local-only config/env below (quick-start rejects them for remote cases). const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); this.terminal.focus(); try { if (!isRemote) { const statusRes = await fetch('/api/pi/status'); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' ); return; } } const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caseName, mode: 'pi', sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }), }) }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Pi'); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); if (data.data.sessionId) { await this.selectSession(data.data.sessionId); } this.terminal.focus(); } catch (err) { this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, // ═══════════════════════════════════════════════════════════════ // 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' || session.mode === 'pi'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); const enableBtn = document.getElementById('modalEnableRespawnBtn'); const stopBtn = document.getElementById('modalStopRespawnBtn'); if (this.respawnStatus[sessionId]) { respawnStatus.classList.add('active'); respawnStatus.querySelector('.respawn-status-text').textContent = this.respawnStatus[sessionId].state || 'Active'; enableBtn.style.display = 'none'; stopBtn.style.display = ''; } else { respawnStatus.classList.remove('active'); respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; enableBtn.style.display = ''; stopBtn.style.display = 'none'; } // Only show respawn section for claude mode sessions with a running process const respawnSection = document.getElementById('sessionRespawnSection'); if (session.mode === 'claude' && session.pid) { respawnSection.style.display = ''; } else { respawnSection.style.display = 'none'; } // Hide Claude-specific options for external CLI sessions const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi'; 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 = 'No exports yet. Export a docker case from its tab.'; 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 `
${nm} (${mb} MB) Download
`; }) .join(''); } catch (err) { listEl.innerHTML = `Failed to load exports: ${err.message}`; } }, 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 = '
Discovering…
'; 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 = `
${escapeHtml(err.message)}
`; } 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 = '
No codeman-* sessions running on this host (or it is unreachable).
'; 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 ? `shared · ${clients} clients` : clients === 1 ? 'attached' : ''; return `
${escapeHtml(s.name)} ${attachedBadge} age ${age} · ${s.windows || 1} window(s)
`; }).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 = '
No cases yet
'; return; } let html = ''; cases.forEach((c, idx) => { const isFirst = idx === 0; const isLast = idx === cases.length - 1; // Was `/Users/` only, the mirror image of the Run menu's bug: every // case path on a Linux host rendered in full, unabbreviated. const pathDisplay = c.path ? this._shortenHomePath(c.path) : ''; html += `
${escapeHtml(c.name)} ${escapeHtml(pathDisplay)}
${ c.location === 'docker' ? `` : '' }
`; }); 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 += ` `; } listContainer.innerHTML = html; modal.classList.add('active'); }, closeMobileCasePicker() { document.getElementById('mobileCasePickerModal').classList.remove('active'); }, selectMobileCase(caseName) { // Update the desktop select (source of truth) const select = document.getElementById('quickStartCase'); select.value = caseName; // Update mobile button label this.updateMobileCaseLabel(caseName); // Update directory display this.updateDirDisplayForCase(caseName); // Save as last used this.saveLastUsedCase(caseName); // Close the picker this.closeMobileCasePicker(); this.showToast(`Selected: ${caseName}`, 'success'); }, updateMobileCaseLabel(caseName) { const label = document.getElementById('mobileCaseName'); if (label) { // Let CSS handle truncation via text-overflow: ellipsis label.textContent = caseName; } }, async deleteCaseMobile(name) { if (!confirm(`Delete case "${name}"?`)) return; try { const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' }); const data = await res.json(); if (data.success) { this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success'); this.cases = (this.cases || []).filter(c => c.name !== name); // Refresh mobile picker and dropdown this.closeMobileCasePicker(); await this.loadQuickStartCases(); } else { this.showToast(data.error || 'Failed to delete case', 'error'); } } catch (err) { this.showToast('Failed to delete case: ' + err.message, 'error'); } }, showCreateCaseFromMobile() { // Close mobile picker first this.closeMobileCasePicker(); // Open the create case modal with slide-up animation this.showCreateCaseModal(); const modal = document.getElementById('createCaseModal'); modal.classList.add('from-mobile'); // Remove animation class after it plays setTimeout(() => modal.classList.remove('from-mobile'), 300); }, }); Object.defineProperty(CodemanApp.prototype, 'runMode', { configurable: true, enumerable: true, get() { return this._runMode || 'claude'; }, set(mode) { this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude' ? mode : 'claude'; }, });