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.
+
+
+
+
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'));