From 7e7bfb66e4fd27478790156084ef1faccf120c31 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 19 Jan 2026 18:25:31 +0100 Subject: [PATCH] fix(ui): fix case creation not updating dropdown immediately - Add cache-busting to /api/cases fetch to prevent stale data - Use created case path from API response (was using old empty caseData) - Add selectCaseName parameter to loadQuickStartCases for post-create selection - Add toast() alias for showToast() to fix "toast is not a function" error - Prevent duplicate event listeners on case dropdown - Show "(will be created)" hint when no cases exist - Add Ralph Wiggum config tab to session options modal - Add inner-config API endpoint for Ralph loop settings Co-Authored-By: Claude Opus 4.5 --- src/web/public/app.js | 124 ++++++++++++++++++++++++++++++++------ src/web/public/index.html | 42 +++++++++++++ src/web/public/styles.css | 43 +++++++++++++ src/web/server.ts | 44 ++++++++++++++ 4 files changed, 235 insertions(+), 18 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index a2b35de5..897de10c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -766,35 +766,54 @@ class ClaudemanApp { // ========== Quick Start ========== - async loadQuickStartCases() { + async loadQuickStartCases(selectCaseName = null) { try { - const res = await fetch('/api/cases'); + // Add cache-busting to ensure fresh data + const res = await fetch('/api/cases?_t=' + Date.now()); const cases = await res.json(); this.cases = cases; + console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name)); const select = document.getElementById('quickStartCase'); - // Build options - let options = ''; + // Build options - existing cases first, then testcase as fallback if not present + let options = ''; + const hasTestcase = cases.some(c => c.name === 'testcase'); + cases.forEach(c => { - if (c.name !== 'testcase') { - options += ``; - } + options += ``; }); - select.innerHTML = options; + // Add testcase option if it doesn't exist (will be created on first run) + if (!hasTestcase) { + options = `` + options; + } - // Auto-select first case and update directory display - if (cases.length > 0) { + select.innerHTML = 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); + } else if (cases.length > 0) { + // Auto-select first case const firstCase = cases.find(c => c.name === 'testcase') || cases[0]; select.value = firstCase.name; this.updateDirDisplayForCase(firstCase.name); + } else { + // No cases exist yet - show the default case name as directory hint + select.value = 'testcase'; + document.getElementById('dirDisplay').textContent = '~/claudeman-cases/testcase (will be created)'; } - // Update directory when case selection changes - select.addEventListener('change', () => { - this.updateDirDisplayForCase(select.value); - }); + // Only add event listener once (on first load) + if (!select.dataset.listenerAdded) { + select.addEventListener('change', () => { + this.updateDirDisplayForCase(select.value); + }); + select.dataset.listenerAdded = 'true'; + } } catch (err) { console.error('Failed to load cases:', err); } @@ -842,7 +861,7 @@ class ClaudemanApp { try { // Get case path first const caseRes = await fetch(`/api/cases/${caseName}`); - const caseData = await caseRes.json(); + let caseData = await caseRes.json(); // Create the case if it doesn't exist if (!caseData.path) { @@ -853,6 +872,8 @@ class ClaudemanApp { }); const createCaseData = await createCaseRes.json(); if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); + // Use the newly created case data (API returns { success, case: { name, path } }) + caseData = createCaseData.case; } const workingDir = caseData.path; @@ -1291,6 +1312,9 @@ class ClaudemanApp { this.editingSessionId = sessionId; + // Reset to Respawn tab + this.switchOptionsTab('respawn'); + // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); const enableBtn = document.getElementById('modalEnableRespawnBtn'); @@ -1320,6 +1344,15 @@ class ClaudemanApp { // Reset duration presets to default (unlimited) this.selectDurationPreset(''); + // Populate Ralph Wiggum form with current session values + const innerState = this.innerStates.get(sessionId); + this.populateRalphForm({ + completionPhrase: innerState?.loop?.completionPhrase || session.innerLoop?.completionPhrase || '', + maxIterations: innerState?.loop?.maxIterations || session.innerLoop?.maxIterations || 0, + maxTodos: session.innerConfig?.maxTodos || 50, + todoExpirationMinutes: session.innerConfig?.todoExpirationMinutes || 60 + }); + document.getElementById('sessionOptionsModal').classList.add('active'); } @@ -1481,6 +1514,58 @@ class ClaudemanApp { this.closeSessionOptions(); } + // ========== Session Options Modal Tabs ========== + + switchOptionsTab(tabName) { + // Toggle active class on tab buttons + document.querySelectorAll('.modal-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // Toggle hidden class on tab content + document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn'); + document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph'); + } + + getRalphConfig() { + return { + 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('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(); + + try { + const res = await fetch(`/api/sessions/${this.editingSessionId}/inner-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); @@ -2168,9 +2253,7 @@ class ClaudemanApp { this.closeCreateCaseModal(); this.toast(`Case "${name}" created`, 'success'); // Reload cases and select the new one - await this.loadQuickStartCases(); - document.getElementById('quickStartCase').value = name; - this.updateDirDisplayForCase(name); + await this.loadQuickStartCases(name); } else { this.toast(data.error || 'Failed to create case', 'error'); } @@ -2249,6 +2332,11 @@ class ClaudemanApp { // Cached toast container for performance _toastContainer = null; + // Alias for showToast + toast(message, type = 'info') { + return this.showToast(message, type); + } + showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; diff --git a/src/web/public/index.html b/src/web/public/index.html index a70fbf49..ef998095 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -268,7 +268,14 @@

Session Options

+ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index fbb18489..ad2ace59 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1713,6 +1713,49 @@ body { gap: 0.5rem; } +/* Modal Tabs */ +.modal-tabs { + display: flex; + gap: 0.5rem; + padding: 0 1rem 0.75rem 1rem; + border-bottom: 1px solid var(--border); +} + +.modal-tab-btn { + padding: 0.5rem 1rem; + font-size: 0.8rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; +} + +.modal-tab-btn:hover { + background: var(--bg-input); + color: var(--text); +} + +.modal-tab-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.modal-tab-content.hidden { + display: none; +} + +/* Ralph Config Actions */ +.ralph-config-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + .modal-header { display: flex; justify-content: space-between; diff --git a/src/web/server.ts b/src/web/server.ts index 883d2329..5387ce14 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -242,6 +242,50 @@ export class WebServer extends EventEmitter { }; }); + // Configure inner loop (Ralph Wiggum) settings + this.app.post('/api/sessions/:id/inner-config', async (req) => { + const { id } = req.params as { id: string }; + const { completionPhrase, maxIterations, maxTodos, todoExpirationMinutes } = req.body as { + completionPhrase?: string; + maxIterations?: number; + maxTodos?: number; + todoExpirationMinutes?: number; + }; + const session = this.sessions.get(id); + + if (!session) { + return { success: false, error: 'Session not found' }; + } + + // Configure the inner loop tracker + if (completionPhrase !== undefined) { + // Start loop with completion phrase to set it up for watching + if (completionPhrase) { + session.innerLoopTracker.startLoop(completionPhrase, maxIterations || undefined); + } + } + + if (maxIterations !== undefined) { + session.innerLoopTracker.setMaxIterations(maxIterations || null); + } + + // Store additional config on session for reference + (session as any).innerConfig = { + completionPhrase: completionPhrase || '', + maxIterations: maxIterations || 0, + maxTodos: maxTodos || 50, + todoExpirationMinutes: todoExpirationMinutes || 60 + }; + + // Broadcast the update + this.broadcast('session:innerLoopUpdate', { + sessionId: id, + state: session.innerLoopState + }); + + return { success: true }; + }); + // Run prompt in session this.app.post('/api/sessions/:id/run', async (req): Promise<{ success?: boolean; message?: string; error?: string }> => { const { id } = req.params as { id: string };