From 607c88afd902aab64cfb36a2e3b9a58f4d0af218 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:01:09 +0800 Subject: [PATCH] fix(settings): a tab-return read never undoes a pick or save made while it was out - session-ui.js: drop the answer when the case-sync chain moved during the read; settings-ui.js calls _applyServerCaseSettings with ?. - i18n.js: zh-CN for the failed case-options save toast, case name passed through - tests: regression for the stale read, zh-CN toast row --- src/web/public/i18n.js | 5 +++++ src/web/public/session-ui.js | 4 +++- src/web/public/settings-ui.js | 2 +- test/settings-save-failure-ui.test.ts | 4 ++++ test/sync-case-state.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index db4e5cd0..ee876429 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1734,6 +1734,11 @@ (_m, what, why) => `${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`, ], + // The case-options save (session-ui.js _pushCaseSettings): the case name passes through. + [ + /^Options for "([^"]+)" \(until the next sync\) saved on this device, not on the server: ([\s\S]+)$/, + (_m, name, why) => `案例“${name}”的选项已保存到本设备(同步前有效),但未保存到服务器:${ZH_CN[why] || why}`, + ], // A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the // notification drawer shows it. The CLI's label passes through. [/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`], diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 0e72c9d6..b4bbcf50 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3775,11 +3775,13 @@ Object.assign(CodemanApp.prototype, { if (this.loadAppSettingsFromStorage?.()?.syncCaseState !== true) return; const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1); try { - await this._caseSyncChain; // this device's own saves land first, so the copy read next includes them + const chain = this._caseSyncChain; + await chain; // this device's own saves land first, so the copy read next includes them const res = await fetch('/api/settings'); if (!res.ok) return; const server = (await res.json())?.data ?? null; if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer + if (this._caseSyncChain !== chain) return; // a save or pick made while the read was out is newer than it this._adoptServerCaseSyncPreference(server); if (server.syncCaseState !== true) return; this._applyServerCaseSettings(server); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 12719345..e1bf1ba1 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -5282,7 +5282,7 @@ Object.assign(CodemanApp.prototype, { // installs that had saved `false` (agent-skill-default-migration.ts). if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled; this.saveAppSettingsToStorage(merged); - this._applyServerCaseSettings(settings); + this._applyServerCaseSettings?.(settings); // Apply notification prefs from server if present (only if localStorage has none) if (notificationPreferences && this.notificationManager) { diff --git a/test/settings-save-failure-ui.test.ts b/test/settings-save-failure-ui.test.ts index f18fd083..aea5b4ee 100644 --- a/test/settings-save-failure-ui.test.ts +++ b/test/settings-save-failure-ui.test.ts @@ -526,6 +526,10 @@ describe('the new toasts in zh-CN', () => { 'Saved on this device, not on the server: the server could not be reached', '已保存到本设备,但未保存到服务器:无法连接到服务器', ], + [ + 'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500', + '案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500', + ], [`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`], ]; for (const [english, chinese] of cases) { diff --git a/test/sync-case-state.test.ts b/test/sync-case-state.test.ts index 12b01724..1de6f845 100644 --- a/test/sync-case-state.test.ts +++ b/test/sync-case-state.test.ts @@ -262,6 +262,30 @@ describe('returning to the tab', () => { expect(unchanged.hidden.size).toBe(0); }); + it('does not undo a pick or a case save made while its read was out', async () => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const { app, select, store } = makeApp({ syncCaseState: true }, async (_url, init) => { + if (init?.method === 'PUT') return { body: { success: true, data: {} } }; + await gate; // the server read this copy before the pick and the save below reached it + return env({ + syncCaseState: true, + lastPickedCase: 'alpha', + caseSettings: { beta: { agentTeams: false, opusContext1m: true } }, + }); + }); + const refresh = app._syncCaseStateFromServer(); + await settle(); + select.value = 'beta'; + await app.saveLastUsedCase('beta'); + app.saveCaseSettings('beta', entry); + await app._caseSyncChain; + release(); + await refresh; + expect(select.value).toBe('beta'); + expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry); + }); + 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();