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

+8 -1
View File
@@ -94,10 +94,16 @@ describe('shared case options', () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({ syncCaseState: true, caseOrder: [oldName], caseSettings: { [oldName]: on, keep: off } });
seed({
syncCaseState: true,
caseOrder: [oldName],
lastPickedCase: oldName,
caseSettings: { [oldName]: on, keep: off },
});
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
expect(readSettings().lastPickedCase).toBe(newName);
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
@@ -110,6 +116,7 @@ describe('what a non-admin is given', () => {
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
lastPickedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
+48 -6
View File
@@ -9,6 +9,8 @@ import vm from 'node:vm';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// The real write-result handling the case sync reuses (a toast for a failed save, read-only after a 403).
const SETTINGS_SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
@@ -18,11 +20,16 @@ function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
const hidden = new Set<string>();
const popover = (id: string) => ({ classList: { add: (c: string) => c === 'hidden' && void hidden.add(id) } });
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => select, addEventListener: () => {} },
document: {
getElementById: (id: string) => (id.startsWith('caseSettingsPopover') ? popover(id) : select),
addEventListener: () => {},
},
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
@@ -38,6 +45,7 @@ function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({
console,
});
vm.runInContext(SRC, ctx);
vm.runInContext(SETTINGS_SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
@@ -46,7 +54,8 @@ function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
return { app, store, calls, toasts, select, settings };
app.closeCasePicker = () => {};
return { app, store, calls, toasts, select, settings, hidden };
}
const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -103,7 +112,6 @@ describe('saving a case', () => {
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a refusal answered with HTTP 200', { body: { success: false, error: 'settings.json is not valid' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
@@ -111,7 +119,8 @@ describe('saving a case', () => {
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not synced');
expect(toasts[0]).toContain('not on the server');
expect(toasts[0]).toContain('until the next sync');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
@@ -130,6 +139,17 @@ describe('saving a case', () => {
});
});
describe('recording the picked case', () => {
it('writes lastPickedCase with lastUsedCase only while sync is on', async () => {
const on = makeApp({ syncCaseState: true });
await on.app.saveLastUsedCase('beta');
expect(on.calls[0].body).toEqual({ lastUsedCase: 'beta', lastPickedCase: 'beta' });
const off = makeApp({});
await off.app.saveLastUsedCase('beta');
expect(off.calls[0].body).toEqual({ lastUsedCase: 'beta' });
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
@@ -170,7 +190,7 @@ describe('returning to the tab', () => {
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', caseSettings: { beta: entry } })
env({ syncCaseState: true, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
@@ -179,7 +199,7 @@ describe('returning to the tab', () => {
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastUsedCase: 'beta', caseSettings: { beta: entry } })
env({ syncCaseState: false, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
@@ -188,6 +208,28 @@ describe('returning to the tab', () => {
expect(app._caseStateSyncOn()).toBe(false);
});
it('does not follow a case a quick-start or an agent started, only one picked in a picker', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', lastPickedCase: 'alpha' })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('closes an open case-options popover when the follow or an import changes what it shows', async () => {
const followed = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'beta' }));
await followed.app._syncCaseStateFromServer();
expect([...followed.hidden].sort()).toEqual(['caseSettingsPopover', 'caseSettingsPopoverMobile']);
const imported = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, caseSettings: { alpha: entry } })
);
await imported.app._syncCaseStateFromServer();
expect(imported.hidden.size).toBe(2);
const unchanged = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'alpha' }));
await unchanged.app._syncCaseStateFromServer();
expect(unchanged.hidden.size).toBe(0);
});
it('asks for nothing on a tab return while this device has sync off', async () => {
const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();