fix(settings): case sync follows only a picked case and keeps its popover and undo honest

- follow lastPickedCase (written by saveLastUsedCase while sync is on), not lastUsedCase, so a quick-start or an agent's spawned worker no longer moves other devices
- close the open case-options popover when the follow or an import changes what it shows; follow through selectQuickStartCase(save:false)
- failed save reuses _noteSettingsWriteResult and says the choice lasts until the next sync
- rename undo renames the shared entry back instead of restoring the whole map; lastPickedCase follows a rename
- caseSettings and lastPickedCase added to the redaction, rename and backup-volatile lists and the docs
This commit is contained in:
Devvyn committed 2026-11-09 11:17:16 +08:00
1 parent f8ff431d30
commit ea27adf104
11 files changed
+104 -43

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
// per user), a session started in another case, every captured prompt, and a Docker case's
// conversation id after a /clear, and a web tab being opened.
'users.json': ['lastLoginAt'],
'settings.json': ['lastUsedCase'],
'settings.json': ['lastUsedCase', 'lastPickedCase'],
'intents.json': ['recentPrompts', 'updatedAt'],
'docker-cases.json': ['lastClaudeSessionId'],
'webviews.json': ['lastOpenedAt'],
+21 -23
View File
@@ -644,10 +644,13 @@ Object.assign(CodemanApp.prototype, {
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
// so it is what other devices follow when case sync is on.
const body = this._caseStateSyncOn?.() ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lastUsedCase: caseName })
body: JSON.stringify(body)
});
} catch (err) {
console.error('Failed to save last used case:', err);
@@ -3706,18 +3709,6 @@ Object.assign(CodemanApp.prototype, {
return this._caseSyncChain;
},
/** Why a settings write did not land, or '' when it did (HTTP status and the response's own error both count). */
async _caseSyncError(res) {
if (!res) return 'the server could not be reached';
let body = null;
try {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch { /* not JSON: the status is all there is */ }
if (!res.ok) return body?.error || `HTTP ${res.status}`;
if (body && (body.success === false || body.error)) return body.error || 'the server refused it';
return '';
},
async _pushCaseSettings(caseName, entry) {
let res = null;
try {
@@ -3729,10 +3720,8 @@ Object.assign(CodemanApp.prototype, {
} catch (err) {
console.error('Failed to sync case settings:', err);
}
// The admin-only refusal marks this page read-only (settings-ui.js), so nothing more is sent from it.
if (await this._noteSettingsWriteRefusal?.(res)) return;
const error = await this._caseSyncError(res);
if (error) this.showToast?.(`Options for "${caseName}" saved on this device, not synced: ${error}`, 'warning');
// A refusal or failure keeps the choice on this device only until the next sync brings the server's copy back.
await this._noteSettingsWriteResult?.(res, `Options for "${caseName}" (until the next sync)`);
},
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
@@ -3741,13 +3730,24 @@ Object.assign(CodemanApp.prototype, {
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
if (this._settingsReadOnly?.()) return;
try {
let changed = false;
for (const [name, s] of Object.entries(server.caseSettings)) {
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
localStorage.setItem('caseSettings_' + name, JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true }));
const next = JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true });
if (localStorage.getItem('caseSettings_' + name) === next) continue;
localStorage.setItem('caseSettings_' + name, next);
changed = true;
}
// An open popover still shows the options it was filled with: closing it keeps a tick from writing them into another case.
if (changed) this._closeCaseSettingsPopovers();
} catch { /* storage blocked: this device keeps what it has */ }
},
_closeCaseSettingsPopovers() {
document.getElementById('caseSettingsPopover')?.classList.add('hidden');
document.getElementById('caseSettingsPopoverMobile')?.classList.add('hidden');
},
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
_adoptServerCaseSyncPreference(server) {
if (typeof server?.syncCaseState !== 'boolean') return;
@@ -3773,12 +3773,10 @@ Object.assign(CodemanApp.prototype, {
if (server.syncCaseState !== true) return;
this._applyServerCaseSettings(server);
const select = document.getElementById('quickStartCase');
const last = server.lastUsedCase;
const last = server.lastPickedCase;
if (select && last && last !== select.value && this.cases?.some((c) => c.name === last)) {
select.value = last;
this.updateDirDisplayForCase(last);
this.updateMobileCaseLabel(last);
this.updateCasePickerInput(last);
this._closeCaseSettingsPopovers();
this.selectQuickStartCase(last, { save: false });
}
} catch { /* offline: try again next time the tab is shown */ }
},
+14 -3
View File
@@ -2024,6 +2024,7 @@ export function registerCaseRoutes(
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
let sharedRenamed = false;
try {
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
@@ -2032,19 +2033,24 @@ export function registerCaseRoutes(
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
const picked = existing.lastPickedCase === name ? newName : undefined;
// The shared run options (syncCaseState) follow the case to its new name.
const shared = renameCaseSettings(existing.caseSettings, name, newName);
if (!order && !last && !shared) return undefined;
if (!order && !last && !picked && !shared) return undefined;
return {
...existing,
...(order ? { caseOrder: order } : {}),
...(last ? { lastUsedCase: last } : {}),
...(picked ? { lastPickedCase: picked } : {}),
...(shared ? { caseSettings: shared } : {}),
};
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase', 'caseSettings'].filter((k) => after[k] !== before[k]);
// caseSettings is undone by renaming its entry back (below), not by restoring the whole map, so a
// per-case save from another device that landed since is kept.
const keys = ['caseOrder', 'lastUsedCase', 'lastPickedCase'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
sharedRenamed = after.caseSettings !== before.caseSettings;
}
}
if (isLinked) {
@@ -2065,7 +2071,12 @@ export function registerCaseRoutes(
// The folder moved but the registries did not follow: put settings.json and the folder back
// rather than leave a case whose name and folder disagree.
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (undo) {
await mutateSettingsFile((current) => {
const back = sharedRenamed ? renameCaseSettings(current.caseSettings, newName, name) : null;
return { ...current, ...undo, ...(back ? { caseSettings: back } : {}) };
}).catch(() => {});
}
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
+2 -1
View File
@@ -161,7 +161,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase` and `caseSettings` name
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` name
* the admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
@@ -170,6 +170,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
delete out.lastPickedCase;
delete out.caseSettings;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
+2
View File
@@ -1461,6 +1461,8 @@ export const SettingsUpdateSchema = z
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
/** The case a person last picked in a case picker; what other devices follow while `syncCaseState` is on. */
lastPickedCase: z.string().max(200).optional(),
/**
* Opt-in, SYNCED, default OFF: per-case run options (Agent Teams, Opus 1M) are kept here instead of
* only in each browser, and a device follows the selected case another device chose.