mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
f8ff431d30
commit
ea27adf104
11 files changed
+104
-43
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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 */ }
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user