diff --git a/docs/reports/new-case-default-group/README.md b/docs/reports/new-case-default-group/README.md new file mode 100644 index 000000000..85d4c84aa --- /dev/null +++ b/docs/reports/new-case-default-group/README.md @@ -0,0 +1,9 @@ +# New-case default session groups (#38) + +The Add Case dialog now loads the owner's available groups for new cases, including Link Remote. The choice persists across creation tabs and is saved before automatically starting a session. Failed saves report that the case exists and prevent automatic startup in the wrong group. + +Rendered desktop/mobile validation used the production markup and styles with the candidate session UI module. Session connections and writes were intercepted. These screenshots use sample group names. Browser regressions cover local/remote linking, stale responses, existing-case editing, and failed group saves. + +![Desktop](desktop.png) + +![Mobile](mobile.png) diff --git a/docs/reports/new-case-default-group/desktop.png b/docs/reports/new-case-default-group/desktop.png new file mode 100644 index 000000000..8ce66c112 Binary files /dev/null and b/docs/reports/new-case-default-group/desktop.png differ diff --git a/docs/reports/new-case-default-group/mobile.png b/docs/reports/new-case-default-group/mobile.png new file mode 100644 index 000000000..fa458c4ea Binary files /dev/null and b/docs/reports/new-case-default-group/mobile.png differ diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index df62239bf..08eea71fb 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4215,6 +4215,7 @@ Object.assign(CodemanApp.prototype, { }, resetCaseEditState() { + this._caseEditDefaultGroupLoadToken = (this._caseEditDefaultGroupLoadToken || 0) + 1; this.caseEditOriginalName = null; this.caseEditOriginalLocation = null; this.caseEditOriginalHostId = null; @@ -4278,10 +4279,13 @@ Object.assign(CodemanApp.prototype, { void this.loadRemoteHostsForCaseModal(); modal.classList.add('active'); document.getElementById('newCaseName').focus(); + void this.loadCaseEditDefaultGroup(null); }, switchCaseModalTab(tabName) { this.caseModalTab = tabName; + const groupRow = document.getElementById('caseEditDefaultGroupRow'); + if (groupRow) groupRow.style.display = tabName === 'case-manage' ? 'none' : ''; const modal = document.getElementById('createCaseModal'); const isEditing = Boolean(this.caseEditOriginalName); // Toggle active class on tab buttons @@ -4347,6 +4351,10 @@ Object.assign(CodemanApp.prototype, { }, async submitCaseModal() { + if (!this.caseEditOriginalName && document.getElementById('caseEditDefaultGroup')?.disabled) { + this.showToast('Default session groups are not loaded yet. Reopen Add Case to retry.', 'error'); + return; + } // Both submit buttons move together: whichever one the user pressed, the // other must show the same pending state and be equally unclickable. const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean); @@ -4454,6 +4462,7 @@ Object.assign(CodemanApp.prototype, { }, async createCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('newCaseName').value.trim(); const description = document.getElementById('newCaseDescription').value.trim(); @@ -4492,12 +4501,14 @@ Object.assign(CodemanApp.prototype, { const data = await res.json(); if (data.success) { + const groupSaved = await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); // Reload cases and select the new one await this.loadQuickStartCases(name); // Save as last used case await this.saveLastUsedCase(name); if (inDocker) { + if (!groupSaved) return; 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). @@ -4558,6 +4569,7 @@ Object.assign(CodemanApp.prototype, { }, async linkCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('linkCaseName').value.trim(); const path = document.getElementById('linkCasePath').value.trim(); @@ -4585,6 +4597,7 @@ Object.assign(CodemanApp.prototype, { const data = await res.json(); if (data.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); this.showToast(`Case "${name}" linked to ${path}`, 'success'); // Reload cases and select the new one @@ -4768,6 +4781,7 @@ Object.assign(CodemanApp.prototype, { }, async cloneCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const url = document.getElementById('cloneRepoUrl').value.trim(); const name = document.getElementById('cloneCaseName').value.trim(); const ref = document.getElementById('cloneRepoRef').value.trim(); @@ -4813,12 +4827,13 @@ Object.assign(CodemanApp.prototype, { // 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); + const groupSaved = await this._saveNewCaseDefaultGroup(name, defaultGroupId); 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(); + if (startSession && groupSaved) 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. @@ -4906,6 +4921,7 @@ Object.assign(CodemanApp.prototype, { }, async linkRemoteCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('remoteCaseName').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim(); const hostId = document.getElementById('remoteHostId').value.trim() @@ -4978,6 +4994,7 @@ Object.assign(CodemanApp.prototype, { }); const caseData = await caseRes.json(); if (caseData.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); this.showToast(`Remote case "${name}" linked`, 'success'); await this.loadQuickStartCases(name); @@ -5554,6 +5571,7 @@ Object.assign(CodemanApp.prototype, { }, async linkDockerCase() { + const defaultGroupId = this._caseEditDefaultGroupId(); const name = document.getElementById('dockerCaseName').value.trim(); const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; @@ -5646,6 +5664,7 @@ Object.assign(CodemanApp.prototype, { }); const caseData = await caseRes.json(); if (caseData.success) { + await this._saveNewCaseDefaultGroup(name, defaultGroupId); this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); @@ -6200,6 +6219,23 @@ Object.assign(CodemanApp.prototype, { return select.value || null; }, + async _saveNewCaseDefaultGroup(caseName, groupId) { + if (!groupId) return true; // New cases already default to Ungrouped. + try { + const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/default-group`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ groupId }), + }); + const json = await res.json(); + if (!res.ok || json.success === false) throw new Error(json.error || 'Save refused'); + return true; + } catch { + // The case already exists: do not report creation failure or retry creation. + this.showToast('Case created, but its default group could not be saved. Set it in Case settings.', 'error'); + return false; + } + }, + async loadCaseEditDefaultGroup(caseName) { const token = (this._caseEditDefaultGroupLoadToken || 0) + 1; this._caseEditDefaultGroupLoadToken = token; @@ -6207,13 +6243,14 @@ Object.assign(CodemanApp.prototype, { const row = document.getElementById('caseEditDefaultGroupRow'); if (row) row.classList.remove('hidden'); try { - const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/default-group`); + const res = await fetch(caseName ? `/api/cases/${encodeURIComponent(caseName)}/default-group` : '/api/tab-layout'); const json = await res.json(); if (!res.ok || json.success === false) throw new Error(json.error || 'Failed to load default group'); if (token !== this._caseEditDefaultGroupLoadToken || this.caseEditOriginalName !== caseName) return; const select = document.getElementById('caseEditDefaultGroup'); if (!select) return; - const groups = Array.isArray(json.data?.groups) ? json.data.groups : []; + const availableGroups = caseName ? json.data?.groups : json.data?.layout?.groups; + const groups = Array.isArray(availableGroups) ? availableGroups : []; const selected = typeof json.data?.groupId === 'string' && groups.some((group) => group.id === json.data.groupId) ? json.data.groupId : ''; diff --git a/test/case-default-group-ui.browser.test.ts b/test/case-default-group-ui.browser.test.ts index df23f4bdd..3895d7c43 100644 --- a/test/case-default-group-ui.browser.test.ts +++ b/test/case-default-group-ui.browser.test.ts @@ -74,6 +74,139 @@ describe('COD-343 Case Management default groups in Chromium', () => { await browser.close(); }); + async function newCaseForm() { + await page.route('**/api/tab-layout', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { layout: { groups } } }), + }) + ); + await page.evaluate(() => { + const app = (window as any).__app; + app.caseEditOriginalName = null; + app.loadQuickStartCases = async () => {}; + app.saveLastUsedCase = async () => {}; + document.body.insertAdjacentHTML( + 'beforeend', + '' + ); + }); + await page.evaluate(() => (window as any).__app.loadCaseEditDefaultGroup(null)); + expect(await page.locator('#caseEditDefaultGroup').isEnabled()).toBe(true); + await page.selectOption('#caseEditDefaultGroup', 'group-tcc'); + } + + it('loads selectable owner groups for a new case and saves the choice before closing Link Case', async () => { + await newCaseForm(); + const requests: unknown[] = []; + await page.route('**/api/cases/link', async (route) => { + requests.push(route.request().postDataJSON()); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.route('**/api/cases/fresh/default-group', async (route) => { + requests.push(route.request().postDataJSON()); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.evaluate(() => (window as any).__app.linkCase()); + expect(requests).toEqual([ + { name: 'fresh', path: '/tmp/fresh', codexCommandSelection: { kind: 'default' } }, + { groupId: 'group-tcc' }, + ]); + expect(await page.evaluate(() => (window as any).__app.caseEditOriginalName)).toBeNull(); + }); + + it('saves a remote case group after linking without contacting a real host', async () => { + await newCaseForm(); + await page.evaluate(() => { + const values: Record = { + remoteCaseName: 'fresh', + remoteCasePath: '/srv/fresh', + remoteHostId: 'test', + remoteHostAddress: 'example.test', + remoteHostUsername: 'test', + remoteHostPort: '', + remoteHostIdentityFile: '', + remoteHostSocksProxy: '', + remoteHostJumpHost: '', + remoteHostWakeMac: '', + remoteHostWakeCommand: '', + remoteHostExtraSshOptions: '', + }; + for (const [id, value] of Object.entries(values)) { + const input = document.createElement('input'); + input.id = id; + input.value = value; + document.body.append(input); + } + }); + for (const endpoint of ['remote-hosts', 'cases/remote-link']) + await page.route(`**/api/${endpoint}`, (route) => + route.fulfill({ contentType: 'application/json', body: '{"success":true}' }) + ); + let saved: unknown; + await page.route('**/api/cases/fresh/default-group', async (route) => { + saved = route.request().postDataJSON(); + await route.fulfill({ contentType: 'application/json', body: '{"success":true}' }); + }); + await page.evaluate(() => (window as any).__app.linkRemoteCase()); + expect(saved).toEqual({ groupId: 'group-tcc' }); + }); + + it('does not start a Docker session if case creation succeeds but saving the group fails', async () => { + await newCaseForm(); + await page.locator('#newCaseDocker').check(); + await page.evaluate(() => { + const app = (window as any).__app; + app._collectDockerQuickSettings = () => ({}); + (window as any).__started = false; + app.runClaude = async () => { + (window as any).__started = true; + }; + }); + await page.route('**/api/cases/docker-quickcreate', (route) => + route.fulfill({ contentType: 'application/json', body: '{"success":true,"data":{}}' }) + ); + await page.route('**/api/cases/fresh/default-group', (route) => + route.fulfill({ status: 400, contentType: 'application/json', body: '{"success":false,"error":"Group removed"}' }) + ); + await page.evaluate(() => (window as any).__app.createCase()); + expect(await page.evaluate(() => (window as any).__started)).toBe(false); + expect(await page.evaluate(() => (window as any).__toasts)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'error', message: expect.stringContaining('Case created, but') }), + ]) + ); + }); + + it('ignores a new-case group response after the dialog has switched to an existing case', async () => { + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + await page.route('**/api/tab-layout', async (route) => { + await waiting; + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { layout: { groups } } }), + }); + }); + const requested = page.waitForRequest('**/api/tab-layout'); + await page.evaluate(() => { + const a = (window as any).__app; + a.caseEditOriginalName = null; + void a.loadCaseEditDefaultGroup(null); + }); + await requested; + await page.evaluate(async () => { + const a = (window as any).__app; + a.caseEditOriginalName = 'sandbox'; + await a.loadCaseEditDefaultGroup('sandbox'); + }); + release(); + await page.waitForTimeout(150); + expect(await page.locator('#caseEditDefaultGroup').inputValue()).toBe('group-tcc'); + }); + it('loads the saved default into the Edit Case control and exposes its selected ID for the case update', async () => { await page.evaluate(() => (window as any).__app.loadCaseEditDefaultGroup('nzbdav'));