Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reports/new-case-default-group/README.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file added docs/reports/new-case-default-group/desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reports/new-case-default-group/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 40 additions & 3 deletions src/web/public/session-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4215,6 +4215,7 @@ Object.assign(CodemanApp.prototype, {
},

resetCaseEditState() {
this._caseEditDefaultGroupLoadToken = (this._caseEditDefaultGroupLoadToken || 0) + 1;
this.caseEditOriginalName = null;
this.caseEditOriginalLocation = null;
this.caseEditOriginalHostId = null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -6200,20 +6219,38 @@ 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;
this._resetCaseEditDefaultGroup();
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
: '';
Expand Down
133 changes: 133 additions & 0 deletions test/case-default-group-ui.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
'<input id="linkCaseName" value="fresh"><input id="linkCasePath" value="/tmp/fresh"><input id="newCaseName" value="fresh"><input id="newCaseDescription"><input type="checkbox" id="newCaseDocker">'
);
});
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<string, string> = {
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<void>((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'));

Expand Down
Loading