dsh-custom-mode 2.2.4 → 2.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -58,15 +58,32 @@ try {
58
58
  * truth, checked for key parity by test/locales.test.mjs).
59
59
  */
60
60
  const ZH = {
61
+ "create.nextStep": "创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。",
62
+ "btn.retryRead": "重试读取",
63
+ "btn.retrySync": "重试生效",
64
+ "api.savedButSyncFailed": "文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。",
65
+ "api.syncFailed": "这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。",
66
+ "api.synchronized": "配置已生效。新会话请从模式菜单选择助手。",
67
+ "api.recoveryRequired": "助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。",
68
+ "msg.completedReadFailed": "{result} 页面刷新失败。请点「重试读取」,无需重复原操作。",
69
+ "msg.synchronized": "配置已生效。",
70
+ "msg.syncFailed": "重新应用配置失败,请重试。",
71
+ "msg.loaded": "已载入到编辑器。尚未保存。",
72
+ "status.reading": "正在读取…",
73
+ "status.saving": "正在保存…",
74
+ "status.creating": "正在创建助手…",
75
+ "status.deleting": "正在删除助手…",
76
+ "status.reordering": "正在保存顺序…",
77
+ "status.repairing": "正在修复…",
78
+ "status.importing": "正在读取导入文件…",
79
+ "status.synchronizing": "正在应用配置…",
61
80
  "nav": "自定义模式",
62
81
  "assistant.heading": "助手",
63
- "assistant.hint": "每个助手单独保存系统提示词、基础模式和插件开关。",
64
- "posture.hint": "这一支只在点「新增助手」时写入。",
82
+ "assistant.hint": "选择要编辑的助手。每位助手独立保存系统提示词、基础模式和插件开关。",
83
+ "posture.hint": "选择起步姿态并命名,再点「新增助手」。",
65
84
  "tree.posture": "起步",
66
- "tree.base": "工具底",
67
- "tree.baseHold": "新建时固定用官方标准模式。",
68
- "tree.baseSkip": "这一支点不了。建好后到「当前助手」里改。",
69
- "tree.baseLocked": "只有官方标准模式是亮的。其余四档是暗的,新建时点不了。",
85
+ "tree.base": "初始基础模式",
86
+ "tree.baseLocked": "新建助手从官方标准模式起步。建好后,可在「当前助手」调整基础模式。",
70
87
  "posture.develop.label": "开发",
71
88
  "posture.develop.note": "官方标准模式,工具按出厂。",
72
89
  "posture.write.label": "写作",
@@ -74,7 +91,7 @@ try {
74
91
  "posture.chat.label": "聊天",
75
92
  "posture.chat.note": "官方标准模式。文件和终端关闭。",
76
93
  "editor.heading": "当前助手",
77
- "editor.hint": "这一支改上面选中的助手。基础模式五档都可以点。",
94
+ "editor.hint": "正在编辑「{name}」。这里的选择只决定编辑对象;新会话请从模式菜单选择助手。",
78
95
  "assistant.empty": "还没有助手,用下面的输入框新建一个。",
79
96
  "assistant.loadingList": "正在读取助手列表…",
80
97
  "assistant.newPlaceholder": "新助手的名字(例如:写作助手)",
@@ -95,7 +112,7 @@ try {
95
112
  "btn.resetHint": "把编辑器换回此助手新建时保存的提示词。只改草稿,保存后才写入。",
96
113
  "btn.delete": "删除",
97
114
  "btn.cancel": "取消",
98
- "msg.created": "已创建。刷新后,新会话的模式菜单里会出现它。",
115
+ "msg.created": "已创建。可继续编辑;使用时请在新会话的模式菜单选择它。",
99
116
  "msg.duplicated": "已复制。两份配置彼此独立。",
100
117
  "msg.deleted": "已删除。",
101
118
  "msg.createFailed": "创建失败",
@@ -135,7 +152,7 @@ try {
135
152
  "api.saved": "已保存({name},{mode})。下一步组装时会重读已保存的提示词。",
136
153
  "api.conflict": "磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。",
137
154
  "api.savedWithExclusiveRows": "已保存({name},{mode})。已关闭不能同时启用的行:{rows}。",
138
- "api.created": "已创建「{name}」。刷新后,新会话的模式菜单里会出现它。",
155
+ "api.created": "已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。",
139
156
  "api.savedPrompt": "已保存系统提示词({name})。下一步组装时会重读这个文件。",
140
157
  "api.duplicated": "已从「{from}」复制。两份互不影响。",
141
158
  "api.deleted": "已删除「{name}」。正在使用它的会话不受影响。",
@@ -218,7 +235,7 @@ try {
218
235
  "tag.essential": "核心",
219
236
  "tag.followPlatform": "随平台",
220
237
  "btn.save": "保存",
221
- "btn.saving": "处理中…",
238
+ "btn.saving": "保存中…",
222
239
  "btn.reload": "重新读取",
223
240
  "btn.reloadDiscard": "放弃修改并重新读取",
224
241
  "btn.reloadConfirm": "放弃未保存的修改并重新读取?",
@@ -277,6 +294,8 @@ try {
277
294
  "row.tool-plugin-manager.label": "插件管理(安装 / 启停)",
278
295
  "row.tool-plugin-manager.note": "供模型安装或开关插件。",
279
296
  "row.tool-presentation.label": "PTC 工具呈现",
297
+ "row.tool-executor-ptc.label": "PTC 程序执行器",
298
+ "row.tool-executor-ptc.note": "通过 run_code 执行程序并调用工具,同时向模型提供工具 SDK。",
280
299
  "row.present.label": "交付文件(present)",
281
300
  "row.command-goal.label": "目标命令",
282
301
  "row.tool-goal.label": "目标",
@@ -301,15 +320,32 @@ try {
301
320
  }
302
321
 
303
322
  const EN = {
323
+ "create.nextStep": "After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.",
324
+ "btn.retryRead": "Retry refresh",
325
+ "btn.retrySync": "Retry activation",
326
+ "api.savedButSyncFailed": "Files are saved, but these assistants are not active yet: {ids}. Select Retry activation; you do not need to repeat the original action.",
327
+ "api.syncFailed": "These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.",
328
+ "api.synchronized": "Configuration is active. Choose an assistant in the mode menu for a new session.",
329
+ "api.recoveryRequired": "Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.",
330
+ "msg.completedReadFailed": "{result} The page could not refresh. Select Retry refresh; do not repeat the original action.",
331
+ "msg.synchronized": "Configuration is active.",
332
+ "msg.syncFailed": "Could not apply the configuration. Please retry.",
333
+ "msg.loaded": "Loaded into the editor. Not saved yet.",
334
+ "status.reading": "Reading…",
335
+ "status.saving": "Saving…",
336
+ "status.creating": "Creating assistant…",
337
+ "status.deleting": "Deleting assistant…",
338
+ "status.reordering": "Saving order…",
339
+ "status.repairing": "Repairing…",
340
+ "status.importing": "Reading import file…",
341
+ "status.synchronizing": "Applying configuration…",
304
342
  "nav": "Custom mode",
305
343
  "assistant.heading": "Assistants",
306
- "assistant.hint": "Each assistant keeps its own system prompt, base mode and plugin switches.",
307
- "posture.hint": "This branch is written only when you click New assistant.",
344
+ "assistant.hint": "Choose an assistant to edit. Each keeps its own system prompt, base mode and plugin switches.",
345
+ "posture.hint": "Choose a starting posture and a name, then select New assistant.",
308
346
  "tree.posture": "Starter",
309
- "tree.base": "Tool base",
310
- "tree.baseHold": "A new assistant always starts on the official Standard mode.",
311
- "tree.baseSkip": "Cannot be chosen on this branch. Change it later under This assistant.",
312
- "tree.baseLocked": "Only the official Standard mode is lit. The other four are dim and cannot be chosen when creating.",
347
+ "tree.base": "Starting base mode",
348
+ "tree.baseLocked": "New assistants start on the official Standard mode. You can change the base mode under This assistant after creating one.",
313
349
  "posture.develop.label": "Coding",
314
350
  "posture.develop.note": "Official Standard mode, tools as shipped.",
315
351
  "posture.write.label": "Writing",
@@ -317,7 +353,7 @@ try {
317
353
  "posture.chat.label": "Chat",
318
354
  "posture.chat.note": "Official Standard mode. Files and terminal off.",
319
355
  "editor.heading": "This assistant",
320
- "editor.hint": "This branch edits the assistant selected above. All five base modes can be chosen.",
356
+ "editor.hint": "Editing {name}. This selection controls what you edit here; choose an assistant in the mode menu when starting a new session.",
321
357
  "assistant.empty": "No assistants yet — create one with the field below.",
322
358
  "assistant.loadingList": "Loading assistants…",
323
359
  "assistant.newPlaceholder": "Name of the new assistant (e.g. Writing assistant)",
@@ -338,7 +374,7 @@ try {
338
374
  "btn.resetHint": "Restores the prompt saved when this assistant was created. The editor changes. Save writes it.",
339
375
  "btn.delete": "Delete",
340
376
  "btn.cancel": "Cancel",
341
- "msg.created": "Created. Refresh once, then it appears in the mode menu for a new session.",
377
+ "msg.created": "Created. Continue editing here, then choose it in the mode menu for a new session.",
342
378
  "msg.duplicated": "Duplicated. The two are independent from here on.",
343
379
  "msg.deleted": "Deleted.",
344
380
  "msg.createFailed": "Could not create",
@@ -378,7 +414,7 @@ try {
378
414
  "api.saved": "Saved ({name}, {mode}). The next model step rereads the saved prompt.",
379
415
  "api.conflict": "The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.",
380
416
  "api.savedWithExclusiveRows": "Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.",
381
- "api.created": "Created {name}. Refresh once, then it appears in the mode menu for a new session.",
417
+ "api.created": "Created {name}. Continue editing here, then choose it in the mode menu for a new session.",
382
418
  "api.savedPrompt": "System prompt saved ({name}). The next model step rereads this file.",
383
419
  "api.duplicated": "Copied from {from}. The two are independent from now on.",
384
420
  "api.deleted": "Deleted {name}. Sessions already using it are unchanged.",
@@ -461,7 +497,7 @@ try {
461
497
  "tag.essential": "Core",
462
498
  "tag.followPlatform": "Platform",
463
499
  "btn.save": "Save",
464
- "btn.saving": "Working…",
500
+ "btn.saving": "Saving…",
465
501
  "btn.reload": "Reload",
466
502
  "btn.reloadDiscard": "Discard edits and reload",
467
503
  "btn.reloadConfirm": "Discard unsaved edits and reload?",
@@ -520,6 +556,8 @@ try {
520
556
  "row.tool-plugin-manager.label": "Plugin manager (install / toggle)",
521
557
  "row.tool-plugin-manager.note": "Lets the model install or toggle plugins.",
522
558
  "row.tool-presentation.label": "PTC tool presentation",
559
+ "row.tool-executor-ptc.label": "PTC program executor",
560
+ "row.tool-executor-ptc.note": "Runs programs and calls tools through run_code, and provides the tool SDK to the model.",
523
561
  "row.present.label": "Deliverables (present)",
524
562
  "row.command-goal.label": "Goal command",
525
563
  "row.tool-goal.label": "Goals",
@@ -779,6 +817,11 @@ try {
779
817
  ".cpfe-pills button{white-space:nowrap}",
780
818
  ".cpfe-picker{display:flex;align-items:center;gap:8px;margin:2px 0 4px}",
781
819
  ".cpfe-history{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}",
820
+ // Native Menu and Button keep their appearance; only the trigger's
821
+ // own text shrinks so long names cannot wrap outside its fixed height.
822
+ ".cpfe-selector-menu,.cpfe-selector{min-width:0;max-width:100%}",
823
+ ".cpfe-selector-label{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
824
+ ".cpfe-selector-chevron{flex:0 0 auto}",
782
825
  ".cpfe-history-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
783
826
  ".cpfe-history-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
784
827
  ".cpfe-pill-wrap{display:inline-flex;align-items:center;gap:4px}",
@@ -789,7 +832,9 @@ try {
789
832
  ".cpfe-branch{display:flex;flex-direction:column;gap:16px;margin-top:8px;padding-left:12px;border-left:1px solid var(--dsw-alias-border-l1)}",
790
833
  ".cpfe-node{display:flex;flex-direction:column;gap:4px}",
791
834
  ".cpfe-node-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
792
- ".cpfe-field{display:flex;width:100%;margin-bottom:8px}",
835
+ // Native Input applies className to its padded wrapper span, so its
836
+ // outer box (not only the inner input) must fit the plugin column.
837
+ ".cpfe-field{display:flex;box-sizing:border-box;width:100%;min-width:0;max-width:100%;margin-bottom:8px}",
793
838
  ".cpfe-row-head{font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}",
794
839
  ".cpfe-row-switch{flex:0 0 auto}",
795
840
  ".cpfe-row-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}",
@@ -802,7 +847,7 @@ try {
802
847
  ".cpfe-detail-value{min-width:0;overflow-wrap:anywhere}",
803
848
  ".cpfe-sec-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}",
804
849
  ".cpfe-sec-head .cpfe-actions{margin-top:0}",
805
- ".cpfe-newinput{flex:1;min-width:200px}",
850
+ ".cpfe-newinput{box-sizing:border-box;flex:1 1 200px;min-width:0;max-width:100%}",
806
851
  // 单列、行高统一(对齐官方插件页的形态);之前的自适应多列网格会让行高参差不齐。
807
852
  // ★ 对齐官方设置行(实测 0.1.7-rc.2 的行:padding 16px 0、无背景、无圆角,行间只有一条 1px
808
853
  // 分隔线)。原来是"圆角卡片 + 描边 + 灰底" —— 那正是廉价感最重的部分。
@@ -880,6 +925,7 @@ try {
880
925
  delete: ROUTE + "/delete",
881
926
  reorder: ROUTE + "/reorder",
882
927
  repair: ROUTE + "/repair",
928
+ sync: ROUTE + "/sync",
883
929
  }
884
930
 
885
931
  /** Every assistant this feature manages. */
@@ -1215,6 +1261,12 @@ try {
1215
1261
  const [status, setStatus] = react.useState("")
1216
1262
  const [failed, setFailed] = react.useState(false)
1217
1263
  const [busy, setBusy] = react.useState(false)
1264
+ const [pending, setPending] = react.useState("")
1265
+ const [syncNeeded, setSyncNeeded] = react.useState(false)
1266
+ const [refreshNeeded, setRefreshNeeded] = react.useState(null)
1267
+ // A ref closes the interval before React renders disabled controls. Only the
1268
+ // owning operation may apply a response or release the busy state.
1269
+ const operation = react.useRef({ alive: true, current: null })
1218
1270
  const [newName, setNewName] = react.useState("")
1219
1271
  const [postureChoices, setPostureChoices] = react.useState([])
1220
1272
  const [posture, setPosture] = react.useState("develop")
@@ -1236,12 +1288,11 @@ try {
1236
1288
  /** 本机这条线上有无法解析的行时,一键把它们关掉(服务端复用保存同一条排版手术)。 */
1237
1289
  const repairRowsNow = async () => {
1238
1290
  if (draft === null) return
1239
- setBusy(true)
1240
- setFailed(false)
1241
- try {
1291
+ return runOperation("status.repairing", "msg.saveFailed", async (token) => {
1242
1292
  const result = await postJson(ROUTES.repair, { id: selected })
1243
- if (result !== null && result.ok === true) {
1244
- setStatus(apiText(result, "msg.repaired"))
1293
+ if (!ownsOperation(token)) return
1294
+ showResult(result, "msg.repaired", "msg.saveFailed")
1295
+ if (result?.ok === true || result?.persisted === true) {
1245
1296
  // ★ 把修复折进**草稿**(issue #8)。
1246
1297
  //
1247
1298
  // 修复在磁盘上是生效的,但草稿里那几行仍然是"启用"。而 `reload()` 对脏草稿是
@@ -1255,17 +1306,9 @@ try {
1255
1306
  for (const rowId of repairedIds) nextOverrides[rowId] = false
1256
1307
  update({ overrides: nextOverrides }, { force: true })
1257
1308
  }
1258
- await reload()
1259
- } else {
1260
- setFailed(true)
1261
- setStatus(apiText(result, "msg.saveFailed"))
1309
+ await refreshAfterWrite(result, "msg.repaired", selected, token, () => reload(selected, undefined, token))
1262
1310
  }
1263
- } catch {
1264
- setFailed(true)
1265
- setStatus(t("msg.saveFailed"))
1266
- } finally {
1267
- setBusy(false)
1268
- }
1311
+ })
1269
1312
  }
1270
1313
 
1271
1314
  const toggleRowExpanded = (id) =>
@@ -1309,6 +1352,52 @@ try {
1309
1352
 
1310
1353
  const describeError = (error) => String((error && error.message) || error)
1311
1354
 
1355
+ const ownsOperation = (token) => operation.current.alive && operation.current.current === token
1356
+ const runOperation = async (pendingKey, failureKey, action) => {
1357
+ if (!operation.current.alive || operation.current.current !== null) return
1358
+ const token = {}
1359
+ operation.current.current = token
1360
+ setBusy(true)
1361
+ setPending(pendingKey)
1362
+ try {
1363
+ await action(token)
1364
+ } catch (error) {
1365
+ if (ownsOperation(token)) {
1366
+ setFailed(true)
1367
+ if (pendingKey === "status.reading" && list === null) setListFailed(true)
1368
+ setStatus(fillPlaceholders(t("status.detail"), { message: t(failureKey), detail: describeError(error) }))
1369
+ }
1370
+ } finally {
1371
+ if (ownsOperation(token)) {
1372
+ operation.current.current = null
1373
+ setBusy(false)
1374
+ setPending("")
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ const showResult = (result, successKey, failureKey) => {
1380
+ setFailed(result?.ok !== true)
1381
+ setStatus(apiText(result, result?.ok === true ? successKey : failureKey))
1382
+ if (result?.synchronized === false) setSyncNeeded(true)
1383
+ else if (result?.synchronized === true) setSyncNeeded(false)
1384
+ }
1385
+
1386
+ // Once the host confirms a mutation, a failed follow-up GET cannot turn
1387
+ // that fact into a failed mutation or invite the user to repeat it.
1388
+ const refreshAfterWrite = async (result, fallbackKey, targetId, token, refresh) => {
1389
+ try {
1390
+ await refresh()
1391
+ if (ownsOperation(token)) setRefreshNeeded(null)
1392
+ } catch (error) {
1393
+ if (!ownsOperation(token)) return
1394
+ setRefreshNeeded({ result, fallbackKey, targetId })
1395
+ setFailed(true)
1396
+ const message = fillPlaceholders(t("msg.completedReadFailed"), { result: apiText(result, fallbackKey) })
1397
+ setStatus(fillPlaceholders(t("status.detail"), { message, detail: describeError(error) }))
1398
+ }
1399
+ }
1400
+
1312
1401
  /**
1313
1402
  * Fetch one assistant into `entries`, keeping any unsaved draft it already has.
1314
1403
  *
@@ -1316,17 +1405,17 @@ try {
1316
1405
  * `force` is "re-read even if this entry is already in memory". Neither is set by
1317
1406
  * ordinary switching: moving between assistants must never race a half-typed prompt.
1318
1407
  */
1319
- const open = async (id, options) => {
1408
+ const open = async (id, options, token) => {
1409
+ if (!ownsOperation(token)) return
1320
1410
  if (typeof id !== "string" || id === "") return
1321
1411
  const force = options !== null && typeof options === "object" && options.force === true
1322
1412
  const discard = options !== null && typeof options === "object" && options.discard === true
1323
1413
  const held = entries[id]
1324
1414
  if (force !== true && held !== undefined) return
1325
1415
  const result = await fetchState(id)
1416
+ if (!ownsOperation(token)) return
1326
1417
  if (result === null || result === undefined || result.ok !== true) {
1327
- setFailed(true)
1328
- setStatus(apiText(result, "msg.readFailed"))
1329
- return
1418
+ throw new Error(apiText(result, "msg.readFailed"))
1330
1419
  }
1331
1420
  const fresh = draftOf(result)
1332
1421
  setEntries((previous) => {
@@ -1334,17 +1423,16 @@ try {
1334
1423
  const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved) ? before.value : fresh
1335
1424
  return { ...previous, [id]: { payload: result, saved: fresh, value: keep } }
1336
1425
  })
1337
- setFailed(false)
1338
1426
  }
1339
1427
 
1340
1428
  /** Re-read the assistant list and force-reload the one it settles on. */
1341
- const reload = async (preferId, options) => {
1429
+ const reload = async (preferId, options, token) => {
1430
+ if (!ownsOperation(token)) return
1342
1431
  const result = await fetchList()
1432
+ if (!ownsOperation(token)) return
1343
1433
  if (result === null || result === undefined || result.ok !== true) {
1344
- setFailed(true)
1345
1434
  setListFailed(true)
1346
- setStatus(apiText(result, "msg.readFailed"))
1347
- return
1435
+ throw new Error(apiText(result, "msg.readFailed"))
1348
1436
  }
1349
1437
  const assistants = Array.isArray(result.assistants) ? result.assistants : []
1350
1438
  const choices = Array.isArray(result.postures) ? result.postures.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string") : []
@@ -1365,7 +1453,7 @@ try {
1365
1453
  setEntries({})
1366
1454
  return
1367
1455
  }
1368
- await open(wanted, { force: true, discard: options !== null && typeof options === "object" && options.discard === true })
1456
+ await open(wanted, { force: true, discard: options !== null && typeof options === "object" && options.discard === true }, token)
1369
1457
  }
1370
1458
 
1371
1459
  /**
@@ -1374,11 +1462,12 @@ try {
1374
1462
  * A save can rename an assistant but never changes ids, so the list needs a new
1375
1463
  * fetch while the selection, the payloads and every draft stay exactly as they are.
1376
1464
  */
1377
- const refreshNames = async () => {
1465
+ const refreshNames = async (token) => {
1378
1466
  const result = await fetchList()
1467
+ if (!ownsOperation(token)) return
1379
1468
  if (result !== null && result !== undefined && result.ok === true) {
1380
1469
  setList(Array.isArray(result.assistants) ? result.assistants : [])
1381
- }
1470
+ } else throw new Error(apiText(result, "msg.readFailed"))
1382
1471
  }
1383
1472
 
1384
1473
  /**
@@ -1397,22 +1486,11 @@ try {
1397
1486
  }, [])
1398
1487
 
1399
1488
  react.useEffect(() => {
1400
- let alive = true
1401
- ;(async () => {
1402
- setBusy(true)
1403
- try {
1404
- await reload("")
1405
- } catch (error) {
1406
- if (alive) {
1407
- setFailed(true)
1408
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1409
- }
1410
- } finally {
1411
- if (alive) setBusy(false)
1412
- }
1413
- })()
1489
+ operation.current.alive = true
1490
+ runOperation("status.reading", "msg.readFailed", (token) => reload("", undefined, token))
1414
1491
  return () => {
1415
- alive = false
1492
+ operation.current.alive = false
1493
+ operation.current.current = null
1416
1494
  }
1417
1495
  }, [])
1418
1496
 
@@ -1430,7 +1508,7 @@ try {
1430
1508
  //
1431
1509
  // `{ force: true }` 是给**内部**调用用的(例如「按本线修复」把结果折进草稿):
1432
1510
  // 那不是用户输入,不受这条守护约束。
1433
- if (busy === true && options?.force !== true) return
1511
+ if (operation.current.current !== null && options?.force !== true) return
1434
1512
  setEntries((previous) => {
1435
1513
  const current = previous[selected]
1436
1514
  if (current === undefined) return previous
@@ -1440,36 +1518,25 @@ try {
1440
1518
  }
1441
1519
 
1442
1520
  const pick = async (id) => {
1443
- if (id === selected) {
1444
- setBusy(true)
1445
- try {
1446
- await open(id, { force: true })
1447
- } catch (error) {
1448
- setFailed(true)
1449
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1450
- } finally {
1451
- setBusy(false)
1452
- }
1453
- return
1454
- }
1455
- setSelected(id)
1456
- setDeleteOpen(false)
1457
- setAcknowledged(false)
1458
- setStatus("")
1459
- setBusy(true)
1460
- try {
1461
- await open(id)
1462
- } catch (error) {
1463
- setFailed(true)
1464
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.readFailed"), detail: describeError(error) }))
1465
- } finally {
1466
- setBusy(false)
1467
- }
1521
+ return runOperation("status.reading", "msg.readFailed", async (token) => {
1522
+ setSelected(id)
1523
+ setDeleteOpen(false)
1524
+ setAcknowledged(false)
1525
+ setStatus("")
1526
+ setFailed(false)
1527
+ await open(id, { force: id === selected }, token)
1528
+ })
1468
1529
  }
1469
1530
 
1531
+ const reread = (discard = false) => runOperation("status.reading", "msg.readFailed", async (token) => {
1532
+ setStatus("")
1533
+ setFailed(false)
1534
+ await reload(selected, { discard }, token)
1535
+ })
1536
+
1470
1537
  /** Create a new assistant, or duplicate the selected one. */
1471
1538
  const create = async (options) => {
1472
- if (busy === true) return
1539
+ if (operation.current.current !== null) return
1473
1540
  const from = options !== null && typeof options === "object" ? options.from : undefined
1474
1541
  const name =
1475
1542
  options !== null && typeof options === "object" && typeof options.name === "string"
@@ -1480,27 +1547,18 @@ try {
1480
1547
  setStatus(t("msg.nameRequired"))
1481
1548
  return
1482
1549
  }
1483
- setBusy(true)
1484
- try {
1550
+ return runOperation("status.creating", "msg.createFailed", async (token) => {
1485
1551
  const body = { name, locale: activeLanguage(localeRef) }
1486
1552
  if (from !== undefined) body.from = from
1487
1553
  else if (postureChoices.some((item) => item.id === posture)) body.posture = posture
1488
1554
  const result = await postJson(ROUTES.create, body)
1489
- if (result !== null && result !== undefined && result.ok === true) {
1555
+ if (!ownsOperation(token)) return
1556
+ showResult(result, from === undefined ? "msg.created" : "msg.duplicated", "msg.createFailed")
1557
+ if (result?.ok === true || result?.persisted === true) {
1490
1558
  setNewName("")
1491
- setFailed(false)
1492
- setStatus(apiText(result, from === undefined ? "msg.created" : "msg.duplicated"))
1493
- await reload(result.id)
1494
- } else {
1495
- setFailed(true)
1496
- setStatus(apiText(result, "msg.createFailed"))
1559
+ await refreshAfterWrite(result, from === undefined ? "msg.created" : "msg.duplicated", result.id, token, () => reload(result.id, undefined, token))
1497
1560
  }
1498
- } catch (error) {
1499
- setFailed(true)
1500
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.createFailed"), detail: describeError(error) }))
1501
- } finally {
1502
- setBusy(false)
1503
- }
1561
+ })
1504
1562
  }
1505
1563
 
1506
1564
  const duplicate = () => create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
@@ -1512,23 +1570,14 @@ try {
1512
1570
  * (the default) and only the roster order changes.
1513
1571
  */
1514
1572
  const move = async (direction) => {
1515
- setBusy(true)
1516
- try {
1573
+ return runOperation("status.reordering", "msg.reorderFailed", async (token) => {
1517
1574
  const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
1518
- if (result !== null && result !== undefined && result.ok === true) {
1519
- setFailed(false)
1520
- setStatus(apiText(result, "msg.reordered"))
1521
- await reload(draft.id)
1522
- } else {
1523
- setFailed(true)
1524
- setStatus(apiText(result, "msg.reorderFailed"))
1575
+ if (!ownsOperation(token)) return
1576
+ showResult(result, "msg.reordered", "msg.reorderFailed")
1577
+ if (result?.ok === true || result?.persisted === true) {
1578
+ await refreshAfterWrite(result, "msg.reordered", draft.id, token, () => reload(draft.id, undefined, token))
1525
1579
  }
1526
- } catch (error) {
1527
- setFailed(true)
1528
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.reorderFailed"), detail: describe(error) }))
1529
- } finally {
1530
- setBusy(false)
1531
- }
1580
+ })
1532
1581
  }
1533
1582
 
1534
1583
  /** Download the prompt being edited (client-side only — the host is not involved). */
@@ -1548,18 +1597,20 @@ try {
1548
1597
  const loadVersion = async () => {
1549
1598
  const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
1550
1599
  if (picked === "" || draft.id === undefined) return
1551
- try {
1600
+ return runOperation("status.reading", "msg.loadFailed", async (token) => {
1552
1601
  // 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
1553
1602
  const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
1554
1603
  const payload = await asJson(response)
1604
+ if (!ownsOperation(token)) return
1555
1605
  if (payload === null || payload.ok !== true) {
1606
+ setFailed(true)
1556
1607
  setStatus(t("msg.loadFailed"))
1557
1608
  return
1558
1609
  }
1559
- update({ prompt: payload.text })
1560
- } catch (error) {
1561
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.loadFailed"), detail: describeError(error) }))
1562
- }
1610
+ update({ prompt: payload.text }, { force: true })
1611
+ setFailed(false)
1612
+ setStatus(t("msg.loaded"))
1613
+ })
1563
1614
  }
1564
1615
 
1565
1616
  const exportPrompt = () => {
@@ -1588,61 +1639,54 @@ try {
1588
1639
  * path (and the same `{{…}}` validation) as anything typed by hand — an import can
1589
1640
  * never silently publish a prompt the renderer would refuse.
1590
1641
  */
1591
- const importPrompt = (event) => {
1642
+ const importPrompt = async (event) => {
1592
1643
  const input = event !== null && event !== undefined ? event.target : undefined
1593
1644
  const file = input !== null && input !== undefined && input.files !== undefined ? input.files[0] : undefined
1594
1645
  // Reset so picking the same file twice in a row fires `change` again.
1595
1646
  if (input !== null && input !== undefined) input.value = ""
1647
+ if (operation.current.current !== null) return
1596
1648
  if (file === undefined || file === null) return
1597
1649
  if (file.size > 1_000_000) {
1598
1650
  setFailed(true)
1599
1651
  setStatus(t("msg.importTooLarge"))
1600
1652
  return
1601
1653
  }
1602
- const reader = new FileReader()
1603
- reader.onload = () => {
1604
- const text = typeof reader.result === "string" ? reader.result : ""
1654
+ return runOperation("status.importing", "msg.importFailed", async (token) => {
1655
+ const text = await new Promise((resolve, reject) => {
1656
+ const reader = new FileReader()
1657
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "")
1658
+ reader.onerror = () => reject(reader.error || new Error(t("msg.importFailed")))
1659
+ reader.onabort = () => reject(new Error(t("msg.importFailed")))
1660
+ reader.readAsText(file, "utf-8")
1661
+ })
1662
+ if (!ownsOperation(token)) return
1605
1663
  if (text.trim() === "") {
1606
1664
  setFailed(true)
1607
1665
  setStatus(t("msg.importEmpty"))
1608
1666
  return
1609
1667
  }
1610
- update({ prompt: text })
1668
+ update({ prompt: text }, { force: true })
1611
1669
  setFailed(false)
1612
1670
  setStatus(t("msg.imported"))
1613
- }
1614
- reader.onerror = () => {
1615
- setFailed(true)
1616
- setStatus(t("msg.importFailed"))
1617
- }
1618
- reader.readAsText(file, "utf-8")
1671
+ })
1619
1672
  }
1620
1673
 
1621
1674
  const remove = async (id) => {
1622
- setBusy(true)
1623
- try {
1675
+ return runOperation("status.deleting", "msg.deleteFailed", async (token) => {
1624
1676
  const result = await postJson(ROUTES.delete, { id })
1625
- if (result !== null && result !== undefined && result.ok === true) {
1677
+ if (!ownsOperation(token)) return
1678
+ showResult(result, "msg.deleted", "msg.deleteFailed")
1679
+ if (result?.ok === true || result?.persisted === true) {
1626
1680
  setDeleteOpen(false)
1627
1681
  setAcknowledged(false)
1628
- setFailed(false)
1629
- setStatus(apiText(result, "msg.deleted"))
1630
1682
  setEntries((previous) => {
1631
1683
  const next = { ...previous }
1632
1684
  delete next[id]
1633
1685
  return next
1634
1686
  })
1635
- await reload("")
1636
- } else {
1637
- setFailed(true)
1638
- setStatus(apiText(result, "msg.deleteFailed"))
1687
+ await refreshAfterWrite(result, "msg.deleted", "", token, () => reload("", undefined, token))
1639
1688
  }
1640
- } catch (error) {
1641
- setFailed(true)
1642
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.deleteFailed"), detail: describeError(error) }))
1643
- } finally {
1644
- setBusy(false)
1645
- }
1689
+ })
1646
1690
  }
1647
1691
 
1648
1692
  /**
@@ -1669,9 +1713,10 @@ try {
1669
1713
  }
1670
1714
 
1671
1715
  const save = async () => {
1672
- setBusy(true)
1673
- try {
1674
- const newest = Array.isArray(draft.history) && draft.history.length > 0 ? draft.history[0].n : undefined
1716
+ if (draft === null) return
1717
+ return runOperation("status.saving", "msg.saveFailed", async (token) => {
1718
+ const savedHistory = entry?.saved?.history
1719
+ const newest = Array.isArray(savedHistory) && savedHistory.length > 0 ? savedHistory[0].n : undefined
1675
1720
  const result = await postJson(ROUTES.state, {
1676
1721
  id: draft.id,
1677
1722
  mode: draft.mode,
@@ -1681,6 +1726,9 @@ try {
1681
1726
  description: draft.description,
1682
1727
  ...(typeof newest === "number" ? { baseN: newest } : {}),
1683
1728
  })
1729
+ if (!ownsOperation(token)) return
1730
+ if (result?.synchronized === false) setSyncNeeded(true)
1731
+ else if (result?.synchronized === true) setSyncNeeded(false)
1684
1732
  if (result !== null && result !== undefined && result.ok === true) {
1685
1733
  setFailed(false)
1686
1734
  const compositionChanged =
@@ -1693,42 +1741,66 @@ try {
1693
1741
  // The server normalises what it stores (trimmed name, recomputed overrides),
1694
1742
  // so the just-saved draft is replaced by what it actually wrote. Without this
1695
1743
  // the page would show 「已保存」 and 「未保存」 at the same time.
1696
- const fresh = await fetchState(draft.id)
1697
- if (fresh !== null && fresh !== undefined && fresh.ok === true) {
1744
+ await refreshAfterWrite(spoken, "msg.saved", draft.id, token, async () => {
1745
+ const fresh = await fetchState(draft.id)
1746
+ if (!ownsOperation(token)) return
1747
+ if (fresh?.ok !== true) throw new Error(apiText(fresh, "msg.readFailed"))
1698
1748
  const normalized = draftOf(fresh)
1699
1749
  setEntries((previous) => ({
1700
1750
  ...previous,
1701
1751
  [draft.id]: { payload: fresh, saved: normalized, value: normalized },
1702
1752
  }))
1703
- await refreshNames()
1704
- } else {
1705
- await reload(draft.id, { discard: true })
1706
- }
1753
+ await refreshNames(token)
1754
+ })
1707
1755
  } else {
1708
1756
  setFailed(true)
1709
1757
  setStatus(apiText(result, "msg.saveFailed"))
1710
1758
  }
1711
- } catch (error) {
1712
- setFailed(true)
1713
- setStatus(fillPlaceholders(t("status.detail"), { message: t("msg.saveFailed"), detail: describeError(error) }))
1714
- } finally {
1715
- setBusy(false)
1759
+ })
1760
+ }
1761
+
1762
+ const retrySync = () => runOperation("status.synchronizing", "msg.syncFailed", async (token) => {
1763
+ const result = await postJson(ROUTES.sync, {})
1764
+ if (!ownsOperation(token)) return
1765
+ showResult(result, "msg.synchronized", "msg.syncFailed")
1766
+ if (result?.ok === true) {
1767
+ await refreshAfterWrite(result, "msg.synchronized", selected, token, () => reload(selected, undefined, token))
1716
1768
  }
1769
+ })
1770
+
1771
+ const retryRead = () => {
1772
+ if (refreshNeeded === null) return
1773
+ const { result, fallbackKey, targetId } = refreshNeeded
1774
+ return runOperation("status.reading", "msg.readFailed", (token) =>
1775
+ refreshAfterWrite(result, fallbackKey, targetId, token, async () => {
1776
+ await reload(targetId, undefined, token)
1777
+ if (ownsOperation(token)) showResult(result, fallbackKey, fallbackKey)
1778
+ }),
1779
+ )
1717
1780
  }
1718
1781
 
1719
1782
  const assistants = list === null ? [] : list
1720
1783
  const position = assistants.findIndex((item) => item.id === selected)
1721
- const statusClass = failed
1784
+ const statusClass = busy
1785
+ ? "cpfe-status"
1786
+ : failed
1722
1787
  ? "cpfe-status cpfe-err"
1723
1788
  : dirty
1724
1789
  ? "cpfe-status cpfe-dirty"
1725
1790
  : "cpfe-status cpfe-ok"
1726
- const shown = dirty && status === "" ? t("msg.unsaved") : status
1727
-
1728
- const lockedModes =
1729
- payload !== null && Array.isArray(payload.modes) && payload.modes.length > 0
1730
- ? payload.modes
1731
- : [{ id: "standard" }, { id: "ptc" }, { id: "minimal" }, { id: "cordis" }, { id: "all" }]
1791
+ const shown = pending !== "" ? t(pending) : dirty && status === "" ? t("msg.unsaved") : status
1792
+ const feedback = [
1793
+ refreshNeeded !== null
1794
+ ? react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: retryRead }, t("btn.retryRead"))
1795
+ : null,
1796
+ refreshNeeded === null && !editorReady && failed && list !== null && selected !== ""
1797
+ ? react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: () => reread() }, t("btn.reload"))
1798
+ : null,
1799
+ syncNeeded
1800
+ ? react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: retrySync }, t("btn.retrySync"))
1801
+ : null,
1802
+ react.createElement("span", { className: statusClass, role: "status", "aria-live": "polite", "aria-atomic": true }, shown),
1803
+ ]
1732
1804
  const createGroup = react.createElement(
1733
1805
  "section",
1734
1806
  { className: "cpfe-create" },
@@ -1766,24 +1838,6 @@ try {
1766
1838
  "div",
1767
1839
  { className: "cpfe-node" },
1768
1840
  react.createElement("span", { className: "cpfe-node-label" }, t("tree.base")),
1769
- react.createElement(
1770
- "div",
1771
- { className: "cpfe-pills cpfe-locked", role: "radiogroup", "aria-disabled": "true", "aria-label": t("tree.base") },
1772
- lockedModes.map((mode) =>
1773
- react.createElement(
1774
- A.Pill,
1775
- {
1776
- key: mode.id,
1777
- active: mode.id === "standard",
1778
- disabled: true,
1779
- "aria-pressed": mode.id === "standard",
1780
- title: mode.id === "standard" ? t("tree.baseHold") : t("tree.baseSkip"),
1781
- onClick: () => {},
1782
- },
1783
- t("base." + mode.id + ".label", mode.label),
1784
- ),
1785
- ),
1786
- ),
1787
1841
  react.createElement("p", { className: "cpfe-note" }, t("tree.baseLocked")),
1788
1842
  ),
1789
1843
  react.createElement(
@@ -1792,6 +1846,7 @@ try {
1792
1846
  react.createElement(A.Input, {
1793
1847
  className: "cpfe-newinput",
1794
1848
  value: newName,
1849
+ disabled: busy,
1795
1850
  placeholder: t("assistant.newPlaceholder"),
1796
1851
  "aria-label": t("assistant.newPlaceholder"),
1797
1852
  onKeyDown: (event) => {
@@ -1810,13 +1865,19 @@ try {
1810
1865
  disabled: busy,
1811
1866
  onClick: () => create(),
1812
1867
  },
1813
- busy ? t("btn.creating") : t("btn.create"),
1868
+ pending === "status.creating" ? t("btn.creating") : t("btn.create"),
1814
1869
  ),
1815
1870
  ),
1871
+ react.createElement("p", { className: "cpfe-note" }, t("create.nextStep")),
1816
1872
  ),
1817
1873
  )
1818
1874
 
1819
1875
  // ── assistant list ────────────────────────────────────────────────────
1876
+ const selectedAssistantLabel = (assistants.find((item) => item.id === selected) || {}).name || selected || t("assistant.heading")
1877
+ const pickedHistoryEntry = draft === null || draft.historyPick === "" ? undefined : draft.history.find((item) => String(item.n) === draft.historyPick)
1878
+ const historySelectionLabel = pickedHistoryEntry === undefined
1879
+ ? t("history.pick")
1880
+ : formatWhen(pickedHistoryEntry.at) + " · " + t("history.by." + pickedHistoryEntry.by)
1820
1881
  const assistantList = react.createElement(
1821
1882
  "section",
1822
1883
  null,
@@ -1828,7 +1889,7 @@ try {
1828
1889
  null,
1829
1890
  react.createElement("p", { className: "cpfe-sub" }, listFailed ? t("msg.readFailed") : t("assistant.loadingList")),
1830
1891
  listFailed
1831
- ? react.createElement(A.Button, { disabled: busy, onClick: () => reload("") }, t("btn.reload"))
1892
+ ? react.createElement(A.Button, { disabled: busy, onClick: () => reread() }, t("btn.reload"))
1832
1893
  : null,
1833
1894
  )
1834
1895
  : assistants.length === 0
@@ -1842,6 +1903,7 @@ try {
1842
1903
  renderable(A.Menu)
1843
1904
  ? react.createElement(A.Menu, {
1844
1905
  open: assistantOpen,
1906
+ className: "cpfe-selector-menu",
1845
1907
  // ★ portal:不把浮层渲染在我们的容器里。设置面板有裁剪与叠层,非 portal 的
1846
1908
  // 菜单会被裁掉表面,只剩文字"压"在下面的输入框上(实测截图如此)。
1847
1909
  portal: true,
@@ -1853,15 +1915,16 @@ try {
1853
1915
  {
1854
1916
  variant: "toolbar",
1855
1917
  className: "cpfe-selector",
1918
+ title: selectedAssistantLabel,
1856
1919
  disabled: busy,
1857
1920
  onClick: () => setAssistantOpen((open) => open !== true),
1858
1921
  },
1859
- (assistants.find((item) => item.id === selected) || {}).name || selected || t("assistant.heading"),
1922
+ react.createElement("span", { className: "cpfe-selector-label" }, selectedAssistantLabel),
1860
1923
  // 官方设置项的下拉是「文字在左、chevron 在右」(实测「通用设置」页的
1861
1924
  // 「工作区内修改 / 中文 / 详细」都是这个形态)⇒ 图标放在 children 末尾,
1862
1925
  // 不走 `icon` prop —— 那个会渲染到文字**前面**,成为页面上唯一一个反向的下拉。
1863
1926
  renderable(A.IconChevronDownOutline14)
1864
- ? react.createElement(A.IconChevronDownOutline14, { size: 14 })
1927
+ ? react.createElement(A.IconChevronDownOutline14, { size: 14, className: "cpfe-selector-chevron", "aria-hidden": true })
1865
1928
  : null,
1866
1929
  ),
1867
1930
  items: assistants.map((item) => ({
@@ -2166,6 +2229,7 @@ try {
2166
2229
  renderable(A.Menu)
2167
2230
  ? react.createElement(A.Menu, {
2168
2231
  open: historyOpen,
2232
+ className: "cpfe-selector-menu",
2169
2233
  portal: true,
2170
2234
  align: "start",
2171
2235
  side: "bottom",
@@ -2175,19 +2239,13 @@ try {
2175
2239
  {
2176
2240
  variant: "toolbar",
2177
2241
  className: "cpfe-selector",
2242
+ title: historySelectionLabel,
2178
2243
  disabled: busy,
2179
2244
  onClick: () => setHistoryOpen((open) => open !== true),
2180
2245
  },
2181
- draft.historyPick === ""
2182
- ? t("history.pick")
2183
- : (() => {
2184
- const entry = draft.history.find((item) => String(item.n) === draft.historyPick)
2185
- return entry === undefined
2186
- ? t("history.pick")
2187
- : formatWhen(entry.at) + " · " + t("history.by." + entry.by)
2188
- })(),
2246
+ react.createElement("span", { className: "cpfe-selector-label" }, historySelectionLabel),
2189
2247
  renderable(A.IconChevronDownOutline14)
2190
- ? react.createElement(A.IconChevronDownOutline14, { size: 14 })
2248
+ ? react.createElement(A.IconChevronDownOutline14, { size: 14, className: "cpfe-selector-chevron", "aria-hidden": true })
2191
2249
  : null,
2192
2250
  ),
2193
2251
  items: draft.history.map((entry) => ({
@@ -2236,9 +2294,9 @@ try {
2236
2294
  "div",
2237
2295
  // `cpfe-busy` 是 issue #6 的兜底:面板里**所有**会改草稿的控件在忙碌期间一律不可交互。
2238
2296
  // 逐个控件加 `disabled` 会漏(本轮就漏了行开关与描述框),一个根类 + 一条 CSS 反而漏不掉。
2239
- { className: busy === true ? "cpfe cpfe-busy" : "cpfe" },
2297
+ { className: busy === true ? "cpfe cpfe-busy" : "cpfe", "aria-busy": busy === true },
2240
2298
  assistantList,
2241
- null,
2299
+ editorReady ? null : react.createElement("div", { className: "cpfe-bar" }, ...feedback),
2242
2300
  // 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
2243
2301
  // `draft` 在没选中任何助手时是 null(列表还没加载完 / 一个都没有)—— 这里必须先守卫,
2244
2302
  // 否则整块设置页崩掉(实测:浏览器验收当场报 Cannot read properties of null)。
@@ -2296,7 +2354,7 @@ try {
2296
2354
  "section",
2297
2355
  null,
2298
2356
  react.createElement("h2", { className: "cpfe-h" }, t("editor.heading")),
2299
- react.createElement(SectionHint, { text: t("editor.hint") }),
2357
+ react.createElement(SectionHint, { text: fillPlaceholders(t("editor.hint"), { name: draft.name || draft.id }) }),
2300
2358
  react.createElement(
2301
2359
  "div",
2302
2360
  { className: "cpfe-branch" },
@@ -2309,10 +2367,10 @@ try {
2309
2367
  {
2310
2368
  variant: "primary",
2311
2369
  icon: A.IconCheckOutline14 === undefined ? null : react.createElement(A.IconCheckOutline14),
2312
- disabled: busy || editorReady === false || dirty === false,
2370
+ disabled: busy || syncNeeded || editorReady === false || dirty === false,
2313
2371
  onClick: save,
2314
2372
  },
2315
- busy ? t("btn.saving") : t("btn.save"),
2373
+ pending === "status.saving" ? t("btn.saving") : t("btn.save"),
2316
2374
  ),
2317
2375
  react.createElement(
2318
2376
  A.Button,
@@ -2325,12 +2383,12 @@ try {
2325
2383
  // one place that does.
2326
2384
  onClick: () => {
2327
2385
  if (dirty && typeof window !== "undefined" && window.confirm(t("btn.reloadConfirm")) !== true) return
2328
- reload(selected, { discard: true })
2386
+ reread(true)
2329
2387
  },
2330
2388
  },
2331
2389
  dirty ? t("btn.reloadDiscard") : t("btn.reload"),
2332
2390
  ),
2333
- react.createElement("span", { className: statusClass }, shown),
2391
+ ...feedback,
2334
2392
  ),
2335
2393
  ...(editorReady ? editorSections.filter((section) => section.key === "name") : []),
2336
2394
  editorReady