dsh-custom-mode 2.2.6 → 2.3.1

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,11 +58,49 @@ try {
58
58
  * truth, checked for key parity by test/locales.test.mjs).
59
59
  */
60
60
  const ZH = {
61
+ "update.heading": "插件更新",
62
+ "update.orphanHint": "以下助手已不在当前列表,但本页仍保留其未保存草稿,因此暂不能离页。",
63
+ "update.orphanName": "{name}(ID:{id})",
64
+ "update.orphanView": "查看并保留草稿",
65
+ "update.orphanHide": "收起草稿",
66
+ "update.orphanCopy": "下方包含完整提示词、名称、描述、模式和覆盖项,可选中复制到本地文件保留。放弃仅移除本页内存草稿,不修改磁盘文件。",
67
+ "update.orphanDiscard": "放弃此草稿…",
68
+ "update.orphanConfirm": "确认放弃“{name}”(ID:{id})的未保存草稿?请先复制保留需要的内容。放弃后无法从本页恢复;磁盘文件不会改动。",
69
+ "update.orphanAcknowledge": "我已保留需要的内容,确认放弃后无法从本页恢复。",
70
+ "update.orphanChanged": "草稿或助手列表已变化,未放弃任何内容。请重新查看并确认。",
71
+ "update.orphanDiscarded": "已放弃所确认的本页草稿;磁盘文件未改动。",
72
+ "update.running": "运行中的插件版本:{version}",
73
+ "update.unknown": "未知",
74
+ "update.check": "检查更新",
75
+ "update.checking": "正在检查…",
76
+ "update.inPlugins": "在插件管理中更新",
77
+ "update.details": "打开插件详情",
78
+ "update.guide": "更新指引",
79
+ "update.idle": "仅在点击检查时查询宿主配置的包仓库;不会自动安装。",
80
+ "update.unavailable": "当前连接未提供原生版本检查能力。可打开插件详情或查看更新指引。",
81
+ "update.permission": "此操作缺少宿主授权,或权限服务暂不可用。",
82
+ "update.updatePermission": "可以查看版本,但当前连接没有更新权限。",
83
+ "update.available": "可用更新:{version}。安装由官方插件管理处理。",
84
+ "update.current": "此次检查未返回更高版本。",
85
+ "update.filtered": "发现较新版本,但没有符合官方默认条件的稳定兼容版本。",
86
+ "update.partial": "结果未覆盖全部新版本,暂未找到可推荐的更新。",
87
+ "update.omitted": "另有 {count} 个较新版本未列出。",
88
+ "update.disk": "磁盘安装版本:{version};它可能尚未在运行中加载。",
89
+ "update.managed": "此安装无法使用原生包仓库更新,请通过原来源或插件详情管理;不会自动切换来源。",
90
+ "update.notFound": "宿主插件列表中没有找到此插件,无法确认更新。",
91
+ "update.network": "宿主查询包仓库失败,请稍后重试;尚不能确认更新状态。",
92
+ "update.refused": "宿主未提供可用的版本列表,尚不能确认更新状态。",
93
+ "update.failed": "未能完成更新检查,请重试或打开插件详情查看。",
94
+ "update.finishOperation": "请等待当前操作完成后再离开此页。",
95
+ "update.finishRecovery": "请先完成重试读取或重试生效,再离开此页。",
96
+ "update.saveDrafts": "请先保存或主动放弃所有助手草稿及未提交的新建输入,再离开此页。",
97
+ "update.navigationFailed": "未能打开插件详情;草稿已保留,可稍后重试。",
61
98
  "create.nextStep": "创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。",
62
99
  "btn.retryRead": "重试读取",
63
100
  "btn.retrySync": "重试生效",
64
101
  "api.savedButSyncFailed": "文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。",
65
102
  "api.syncFailed": "这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。",
103
+ "api.presetRegistrationSyncFailed": "尚未确认已保存的助手配置在宿主注册表中生效,请处理已有告警或查看宿主日志。",
66
104
  "api.synchronized": "配置已生效。新会话请从模式菜单选择助手。",
67
105
  "api.recoveryRequired": "助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。",
68
106
  "msg.completedReadFailed": "{result} 页面刷新失败。请点「重试读取」,无需重复原操作。",
@@ -101,6 +139,9 @@ try {
101
139
  "assistant.brokenHint": "此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。",
102
140
  "assistant.switchHint": "切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。",
103
141
  "btn.create": "新增助手",
142
+ "btn.newAssistant": "新建助手…",
143
+ "assistant.duplicateSavedConfirm": "复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。",
144
+ "prompt.replaceConfirm": "替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。",
104
145
  "create.heading": "新建助手",
105
146
  "btn.creating": "创建中…",
106
147
  "btn.duplicate": "复制",
@@ -150,7 +191,7 @@ try {
150
191
  "meta.version": "插件版本",
151
192
  "meta.versionHint": "未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。",
152
193
  "api.saved": "已保存({name},{mode})。下一步组装时会重读已保存的提示词。",
153
- "api.conflict": "磁盘上的提示词已经变了,这次没有写入。先重新读取,再决定要不要覆盖。",
194
+ "api.conflict": "磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。",
154
195
  "api.savedWithExclusiveRows": "已保存({name},{mode})。已关闭不能同时启用的行:{rows}。",
155
196
  "api.created": "已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。",
156
197
  "api.savedPrompt": "已保存系统提示词({name})。下一步组装时会重读这个文件。",
@@ -188,6 +229,8 @@ try {
188
229
  "warn.approvalGateMissing": "当前版本没有审批事件。会话内改写提示词不会请求确认。",
189
230
  "warn.unresolvableRows": "有的行在本机无法启动,此模式不会出现在新会话菜单里。点「按本线修复」只关闭那些行。",
190
231
  "warn.presetBroken": "此模式无法启动,不会出现在新会话菜单里。点「按本线修复」。原因见下一行。",
232
+ "warn.presetRegistrationSyncFailed": "尚未确认这位助手已保存的配置在宿主注册表中生效。请先处理已有告警或查看宿主日志;无需重复保存。",
233
+ "warn.presetRegistrationRestartSuggested": "尚未确认这位助手已保存的配置在宿主注册表中生效。如刚更新或恢复插件,建议重启 DeepSeek Harness 后重新检查;无需重复保存。",
191
234
  "warn.badPromptVariable": "提示词里有不可用的变量。改掉后再保存,否则此模式的请求会失败。",
192
235
  "warn.duplicateRowIds": "组成文件里有重复的行 id。开关只作用于第一处。",
193
236
  "warn.predicateFailed": "有一行的平台条件无法计算,已按关闭处理。",
@@ -320,11 +363,49 @@ try {
320
363
  }
321
364
 
322
365
  const EN = {
366
+ "update.heading": "Plugin updates",
367
+ "update.orphanHint": "These assistants left the current list, but this page still holds their unsaved drafts. Resolve them before leaving.",
368
+ "update.orphanName": "{name} (ID: {id})",
369
+ "update.orphanView": "View and keep draft",
370
+ "update.orphanHide": "Hide draft",
371
+ "update.orphanCopy": "The text below includes the full prompt, name, description, mode and overrides. Select and copy it to a local file to keep it. Discarding only removes this page's in-memory draft; files on disk are unchanged.",
372
+ "update.orphanDiscard": "Discard this draft…",
373
+ "update.orphanConfirm": "Discard the unsaved draft for \"{name}\" (ID: {id})? First copy any content you need to keep. This page cannot recover it after discarding; files on disk will remain unchanged.",
374
+ "update.orphanAcknowledge": "I have kept the content I need and understand this page cannot recover the discarded draft.",
375
+ "update.orphanChanged": "The draft or assistant list changed. Nothing was discarded. Review it again before confirming.",
376
+ "update.orphanDiscarded": "The confirmed draft was discarded from this page; files on disk are unchanged.",
377
+ "update.running": "Running plugin version: {version}",
378
+ "update.unknown": "unknown",
379
+ "update.check": "Check for updates",
380
+ "update.checking": "Checking…",
381
+ "update.inPlugins": "Update in Plugins",
382
+ "update.details": "Open plugin details",
383
+ "update.guide": "Update guide",
384
+ "update.idle": "Only a click checks the registries configured by the host; installation is never automatic.",
385
+ "update.unavailable": "This connection does not offer native version checks. Open plugin details or the update guide.",
386
+ "update.permission": "Host permission is missing for this action, or the access service is unavailable.",
387
+ "update.updatePermission": "You can view versions, but this connection does not have update permission.",
388
+ "update.available": "Update available: {version}. Official plugin management handles installation.",
389
+ "update.current": "This check returned no newer releases.",
390
+ "update.filtered": "Newer releases exist, but none meet the official default conditions for a stable compatible version.",
391
+ "update.partial": "The result omits some newer releases; no recommended update was found.",
392
+ "update.omitted": "{count} additional newer releases were omitted.",
393
+ "update.disk": "Installed on disk: {version}; this version may not be loaded in the running host yet.",
394
+ "update.managed": "Manage this installation through its original source or plugin details; it cannot use this native registry update path. The source will not be changed automatically.",
395
+ "update.notFound": "The host did not list this plugin, so its update status could not be determined.",
396
+ "update.network": "The host could not query the registry. Try again later; update status is unknown.",
397
+ "update.refused": "The host did not provide a usable version list; update status is unknown.",
398
+ "update.failed": "The update check could not finish. Retry or open plugin details.",
399
+ "update.finishOperation": "Wait for the current operation to finish before leaving this page.",
400
+ "update.finishRecovery": "Complete Retry refresh or Retry activation before leaving this page.",
401
+ "update.saveDrafts": "Save or explicitly discard all assistant drafts and unsubmitted new-assistant input before leaving this page.",
402
+ "update.navigationFailed": "Plugin details could not open. Your drafts are preserved; try again later.",
323
403
  "create.nextStep": "After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.",
324
404
  "btn.retryRead": "Retry refresh",
325
405
  "btn.retrySync": "Retry activation",
326
406
  "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
407
  "api.syncFailed": "These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.",
408
+ "api.presetRegistrationSyncFailed": "The saved assistant configuration has not been confirmed in the host registry. Address existing warnings or check the host log.",
328
409
  "api.synchronized": "Configuration is active. Choose an assistant in the mode menu for a new session.",
329
410
  "api.recoveryRequired": "Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.",
330
411
  "msg.completedReadFailed": "{result} The page could not refresh. Select Retry refresh; do not repeat the original action.",
@@ -363,6 +444,9 @@ try {
363
444
  "assistant.brokenHint": "This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.",
364
445
  "assistant.switchHint": "Switching assistants keeps each draft; the \"Unsaved\" marker in the list is exactly that.",
365
446
  "btn.create": "New assistant",
447
+ "btn.newAssistant": "New assistant…",
448
+ "assistant.duplicateSavedConfirm": "Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.",
449
+ "prompt.replaceConfirm": "Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.",
366
450
  "create.heading": "New assistant",
367
451
  "btn.creating": "Creating…",
368
452
  "btn.duplicate": "Duplicate",
@@ -412,7 +496,7 @@ try {
412
496
  "meta.version": "Plugin version",
413
497
  "meta.versionHint": "Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.",
414
498
  "api.saved": "Saved ({name}, {mode}). The next model step rereads the saved prompt.",
415
- "api.conflict": "The prompt on disk has changed, so this save was not written. Reload, then decide whether to overwrite.",
499
+ "api.conflict": "The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.",
416
500
  "api.savedWithExclusiveRows": "Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.",
417
501
  "api.created": "Created {name}. Continue editing here, then choose it in the mode menu for a new session.",
418
502
  "api.savedPrompt": "System prompt saved ({name}). The next model step rereads this file.",
@@ -453,6 +537,8 @@ try {
453
537
  "warn.approvalGateMissing": "This version has no approval event. In-session prompt edits are not confirmed.",
454
538
  "warn.unresolvableRows": "Some rows cannot start here, so this mode is absent from the new-session menu. Fix for this line turns off only those rows.",
455
539
  "warn.presetBroken": "This mode cannot start, so it will not appear for a new session. Use Fix for this line. The reason is on the next line.",
540
+ "warn.presetRegistrationSyncFailed": "This assistant's saved configuration has not been confirmed in the host registry. Address existing warnings or check the host log; you do not need to save again.",
541
+ "warn.presetRegistrationRestartSuggested": "This assistant's saved configuration has not been confirmed in the host registry. If the plugin was just updated or restored, consider restarting DeepSeek Harness, then check again; you do not need to save again.",
456
542
  "warn.badPromptVariable": "The prompt uses a variable that is not available. Fix it before saving.",
457
543
  "warn.duplicateRowIds": "A row id is repeated. A switch applies only to the first one.",
458
544
  "warn.predicateFailed": "A platform condition could not be evaluated. That row is treated as off.",
@@ -644,6 +730,47 @@ try {
644
730
  */
645
731
  let localeRef
646
732
 
733
+ const UPDATE_PACKAGE = "dsh-custom-mode"
734
+ let updateServices = { manager: null, navigation: null, list: false, versions: false, update: false, accessKnown: false, revision: 0 }
735
+ const updateListeners = new Set()
736
+ const publishUpdateServices = (patch) => {
737
+ updateServices = { ...updateServices, ...patch, revision: updateServices.revision + 1 }
738
+ for (const listener of updateListeners) listener()
739
+ }
740
+
741
+ /** Optional Cordis scopes own these reads; old hosts never block the editor. */
742
+ function mountUpdateServices(ctx) {
743
+ if (typeof ctx.inject !== "function") return
744
+ ctx.inject(["remote", "remote.pluginManager"], (scope) => {
745
+ const manager = scope.remote.pluginManager
746
+ if (manager === null || manager === undefined) return
747
+ publishUpdateServices({ manager, list: false, versions: false, update: false, accessKnown: false })
748
+ scope.effect(() => () => {
749
+ if (updateServices.manager === manager) publishUpdateServices({ manager: null, list: false, versions: false, update: false, accessKnown: false })
750
+ })
751
+ scope.inject(["remoteAccess"], (accessScope) => {
752
+ if (typeof accessScope.remoteAccess.whenAccess !== "function") return
753
+ if (updateServices.manager === manager) publishUpdateServices({ accessKnown: true })
754
+ for (const [key, action] of [["list", "listBundles"], ["versions", "listVersions"], ["update", "updateBundle"]]) {
755
+ if (typeof manager[action] !== "function") continue
756
+ accessScope.effect(() => accessScope.remoteAccess.whenAccess("pluginManager/" + action, () => {
757
+ if (updateServices.manager === manager) publishUpdateServices({ [key]: true })
758
+ return () => {
759
+ if (updateServices.manager === manager) publishUpdateServices({ [key]: false })
760
+ }
761
+ }))
762
+ }
763
+ })
764
+ })
765
+ ctx.inject(["pluginNavigation"], (scope) => {
766
+ const navigation = scope.get("pluginNavigation")
767
+ publishUpdateServices({ navigation })
768
+ scope.effect(() => () => {
769
+ if (updateServices.navigation === navigation) publishUpdateServices({ navigation: null })
770
+ })
771
+ })
772
+ }
773
+
647
774
  /**
648
775
  * Plain-element stand-ins for the shell's atoms.
649
776
  *
@@ -847,6 +974,14 @@ try {
847
974
  ".cpfe-detail-value{min-width:0;overflow-wrap:anywhere}",
848
975
  ".cpfe-sec-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}",
849
976
  ".cpfe-sec-head .cpfe-actions{margin-top:0}",
977
+ ".cpfe-update{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:16px 0;border-bottom:1px solid var(--dsw-alias-border-l1);margin-bottom:16px}",
978
+ ".cpfe-update-meta{flex:1 1 220px;min-width:0}",
979
+ ".cpfe-update-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;max-width:100%}",
980
+ // The shared atoms expose no Link or anchor-shaped Button. Keep a real anchor;
981
+ // use official settings UI typography/link tokens, not Markdown prose styles.
982
+ ".cpfe-update-guide{max-width:100%;overflow-wrap:anywhere;font:var(--dsw-font-s-strong-14);color:var(--dsw-alias-link);text-decoration:none}",
983
+ "@media (hover:hover){.cpfe-update-guide:hover{text-decoration:underline}}",
984
+ ".cpfe-update-note{overflow-wrap:anywhere}",
850
985
  ".cpfe-newinput{box-sizing:border-box;flex:1 1 200px;min-width:0;max-width:100%}",
851
986
  // 单列、行高统一(对齐官方插件页的形态);之前的自适应多列网格会让行高参差不齐。
852
987
  // ★ 对齐官方设置行(实测 0.1.7-rc.2 的行:padding 16px 0、无背景、无圆角,行间只有一条 1px
@@ -902,11 +1037,15 @@ try {
902
1037
  ".cpfe-warn svg{flex:0 0 auto;margin-top:2px}",
903
1038
  ].join("")
904
1039
 
905
- /** Apply the page stylesheet once, keyed so a re-mount never duplicates it. */
1040
+ /** Keep one stylesheet, replacing stale content when an HMR bundle arrives. */
906
1041
  function ensureStyles() {
907
1042
  if (typeof document === "undefined") return
908
1043
  const tagId = "dsh-custom-mode/system-prompt"
909
- if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return
1044
+ const existing = document.querySelector('style[data-plugin-css="' + tagId + '"]')
1045
+ if (existing !== null) {
1046
+ if (existing.textContent !== CSS) existing.textContent = CSS
1047
+ return
1048
+ }
910
1049
  const tag = document.createElement("style")
911
1050
  tag.dataset.plugin = "dsh-custom-mode"
912
1051
  tag.dataset.pluginCss = tagId
@@ -1006,6 +1145,39 @@ try {
1006
1145
  )
1007
1146
  }
1008
1147
 
1148
+ // Private to this module instance in this document. Leaving a settings
1149
+ // section must not lose a draft; no prompt is written to browser storage.
1150
+ const draftContexts = new WeakMap()
1151
+ function mountDraftProtection(ctx) {
1152
+ let session = draftContexts.get(ctx)
1153
+ if (session === undefined) {
1154
+ const memory = { entries: {}, selected: "", newName: "", syncNeeded: false, refreshNeeded: null }
1155
+ session = { memory, owners: new Set(), beforeUnload: (event) => {
1156
+ if (memory.newName === "" && Object.keys(memory.entries).length === 0) return
1157
+ event.preventDefault()
1158
+ event.returnValue = ""
1159
+ } }
1160
+ draftContexts.set(ctx, session)
1161
+ }
1162
+ ctx.effect(() => {
1163
+ const owner = {}
1164
+ session.owners.add(owner)
1165
+ if (session.owners.size === 1 && typeof window.addEventListener === "function") window.addEventListener("beforeunload", session.beforeUnload)
1166
+ return () => {
1167
+ session.owners.delete(owner)
1168
+ if (session.owners.size !== 0) return
1169
+ if (typeof window.removeEventListener === "function") window.removeEventListener("beforeunload", session.beforeUnload)
1170
+ session.memory.entries = {}
1171
+ session.memory.selected = ""
1172
+ session.memory.newName = ""
1173
+ session.memory.syncNeeded = false
1174
+ session.memory.refreshNeeded = null
1175
+ draftContexts.delete(ctx)
1176
+ }
1177
+ }, "custom-mode: draft protection")
1178
+ return session.memory
1179
+ }
1180
+
1009
1181
  /** `「名称」副本`-style copy name, from a locale template. */
1010
1182
  function copyName(template, name) {
1011
1183
  return String(template).replace("{name}", name)
@@ -1192,6 +1364,7 @@ try {
1192
1364
  }
1193
1365
 
1194
1366
  function CustomModeSection(props) {
1367
+ const draftMemory = props.draftMemory
1195
1368
  // Compatibility: a shell that does not hand us a bound `t` (older or
1196
1369
  // changed settings contract) must still render real copy. Falling back to
1197
1370
  // the Chinese dictionary means the worst case is "Chinese text", never a
@@ -1254,20 +1427,52 @@ try {
1254
1427
  return t(fallbackKey)
1255
1428
  }
1256
1429
 
1257
- const [list, setList] = react.useState(null)
1258
- const [selected, setSelected] = react.useState("")
1430
+ const [list, setListState] = react.useState(null)
1431
+ const heldRoster = react.useRef(list)
1432
+ const setList = (value) => { heldRoster.current = value; setListState(value) }
1433
+ const [selected, setSelectedState] = react.useState(draftMemory.selected)
1434
+ const setSelected = (value) => { draftMemory.selected = value; setSelectedState(value) }
1259
1435
  /** id → { payload, saved, value }: loaded state, baseline and live draft. */
1260
- const [entries, setEntries] = react.useState({})
1436
+ const [entries, setEntriesState] = react.useState(draftMemory.entries)
1437
+ const heldEntries = react.useRef(entries)
1438
+ const setEntries = (next) => {
1439
+ heldEntries.current = typeof next === "function" ? next(heldEntries.current) : next
1440
+ draftMemory.entries = Object.fromEntries(Object.entries(heldEntries.current).filter(([, held]) => !sameDraft(held.value, held.saved)))
1441
+ setEntriesState(heldEntries.current)
1442
+ }
1261
1443
  const [status, setStatus] = react.useState("")
1262
1444
  const [failed, setFailed] = react.useState(false)
1263
1445
  const [busy, setBusy] = react.useState(false)
1264
1446
  const [pending, setPending] = react.useState("")
1265
- const [syncNeeded, setSyncNeeded] = react.useState(false)
1266
- const [refreshNeeded, setRefreshNeeded] = react.useState(null)
1447
+ const [syncNeeded, setSyncNeededState] = react.useState(draftMemory.syncNeeded)
1448
+ const [refreshNeeded, setRefreshNeededState] = react.useState(draftMemory.refreshNeeded)
1449
+ const navigationState = react.useRef({ syncNeeded, refreshNeeded, newName: draftMemory.newName })
1450
+ const setSyncNeeded = (value) => { draftMemory.syncNeeded = value; navigationState.current.syncNeeded = value; setSyncNeededState(value) }
1451
+ const setRefreshNeeded = (value) => { draftMemory.refreshNeeded = value; navigationState.current.refreshNeeded = value; setRefreshNeededState(value) }
1267
1452
  // A ref closes the interval before React renders disabled controls. Only the
1268
1453
  // owning operation may apply a response or release the busy state.
1269
1454
  const operation = react.useRef({ alive: true, current: null })
1270
- const [newName, setNewName] = react.useState("")
1455
+ const [newName, setNewNameState] = react.useState(draftMemory.newName)
1456
+ const setNewName = (value) => { draftMemory.newName = value; navigationState.current.newName = value; setNewNameState(value) }
1457
+ const createSection = react.useRef(null)
1458
+ const createNameInput = react.useRef(null)
1459
+ const editorSection = react.useRef(null)
1460
+ const [focusCreated, setFocusCreated] = react.useState("")
1461
+ const focusCreate = () => {
1462
+ createSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
1463
+ createNameInput.current?.focus?.({ preventScroll: true })
1464
+ }
1465
+ const [runtimeVersion, setRuntimeVersion] = react.useState("")
1466
+ const [updateResult, setUpdateResult] = react.useState(null)
1467
+ const [updateNotice, setUpdateNotice] = react.useState("")
1468
+ const [updateChecking, setUpdateChecking] = react.useState(false)
1469
+ const [, setUpdateRevision] = react.useState(0)
1470
+ const updateOperation = react.useRef({ alive: true, current: null })
1471
+ const [orphanView, setOrphanView] = react.useState("")
1472
+ const [orphanDialog, setOrphanDialog] = react.useState(null)
1473
+ const [orphanAcknowledged, setOrphanAcknowledged] = react.useState(false)
1474
+ const orphanConfirmation = react.useRef(null)
1475
+ const orphanAcknowledgement = react.useRef(false)
1271
1476
  const [postureChoices, setPostureChoices] = react.useState([])
1272
1477
  const [posture, setPosture] = react.useState("develop")
1273
1478
  const [deleteOpen, setDeleteOpen] = react.useState(false)
@@ -1289,7 +1494,9 @@ try {
1289
1494
  const repairRowsNow = async () => {
1290
1495
  if (draft === null) return
1291
1496
  return runOperation("status.repairing", "msg.saveFailed", async (token) => {
1292
- const result = await postJson(ROUTES.repair, { id: selected })
1497
+ const baseRevision = entry?.payload?.revision
1498
+ const result = await postJson(ROUTES.repair, { id: selected,
1499
+ ...(typeof baseRevision === "string" ? { baseRevision } : {}) })
1293
1500
  if (!ownsOperation(token)) return
1294
1501
  showResult(result, "msg.repaired", "msg.saveFailed")
1295
1502
  if (result?.ok === true || result?.persisted === true) {
@@ -1302,9 +1509,17 @@ try {
1302
1509
  const repairedIds = Array.isArray(result.params?.repairedIds) ? result.params.repairedIds : []
1303
1510
  if (repairedIds.length > 0 && draft !== null) {
1304
1511
  // 草稿里的 `overrides` 是"显式**启用**"语义,所以关闭 = false。
1305
- const nextOverrides = { ...draft.overrides }
1306
- for (const rowId of repairedIds) nextOverrides[rowId] = false
1307
- update({ overrides: nextOverrides }, { force: true })
1512
+ setEntries((previous) => {
1513
+ const before = previous[selected]
1514
+ const overrides = { ...before.value.overrides }
1515
+ const savedOverrides = { ...before.saved.overrides }
1516
+ for (const rowId of repairedIds) { overrides[rowId] = false; savedOverrides[rowId] = false }
1517
+ const ownRevision = typeof result.revision === "string" && before.payload.revision === baseRevision
1518
+ return { ...previous, [selected]: { ...before,
1519
+ value: { ...before.value, overrides },
1520
+ ...(ownRevision ? { payload: { ...before.payload, revision: result.revision },
1521
+ saved: { ...before.saved, overrides: savedOverrides } } : {}) } }
1522
+ })
1308
1523
  }
1309
1524
  await refreshAfterWrite(result, "msg.repaired", selected, token, () => reload(selected, undefined, token))
1310
1525
  }
@@ -1342,6 +1557,12 @@ try {
1342
1557
  const payload = entry === undefined ? null : entry.payload
1343
1558
  const editorReady = draft !== null && payload !== null
1344
1559
  const dirty = editorReady && !sameDraft(draft, entry.saved)
1560
+ react.useEffect(() => {
1561
+ if (focusCreated === "" || focusCreated !== selected || !editorReady) return
1562
+ editorSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
1563
+ editorSection.current?.focus?.({ preventScroll: true })
1564
+ setFocusCreated("")
1565
+ }, [focusCreated, selected, editorReady])
1345
1566
 
1346
1567
  /** Which assistants hold unsaved edits — the list marks them. */
1347
1568
  const dirtyIds = new Set()
@@ -1420,8 +1641,14 @@ try {
1420
1641
  const fresh = draftOf(result)
1421
1642
  setEntries((previous) => {
1422
1643
  const before = previous[id]
1423
- const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved) ? before.value : fresh
1424
- return { ...previous, [id]: { payload: result, saved: fresh, value: keep } }
1644
+ const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved)
1645
+ // A dirty draft owns the revision it was loaded from. A read may
1646
+ // discover someone else's change, but cannot authorize overwriting it.
1647
+ if (keep && typeof before.payload?.revision === "string") return { ...previous, [id]: { ...before,
1648
+ // Diagnostics may recover independently of this draft. Refresh
1649
+ // those fields without changing its editable baseline or token.
1650
+ value: { ...before.value, warnings: fresh.warnings, broken: fresh.broken, baseUnavailable: fresh.baseUnavailable } } }
1651
+ return { ...previous, [id]: { payload: result, saved: fresh, value: keep ? before.value : fresh } }
1425
1652
  })
1426
1653
  }
1427
1654
 
@@ -1437,6 +1664,7 @@ try {
1437
1664
  const assistants = Array.isArray(result.assistants) ? result.assistants : []
1438
1665
  const choices = Array.isArray(result.postures) ? result.postures.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string") : []
1439
1666
  setListFailed(false)
1667
+ if (typeof result.version === "string" && result.version !== "") setRuntimeVersion(result.version)
1440
1668
  setList(assistants)
1441
1669
  setPostureChoices(choices)
1442
1670
  setPosture((current) => (choices.some((item) => item.id === current) ? current : choices.length > 0 ? choices[0].id : "develop"))
@@ -1450,7 +1678,8 @@ try {
1450
1678
  : ""
1451
1679
  setSelected(wanted)
1452
1680
  if (wanted === "") {
1453
- setEntries({})
1681
+ // An empty remote roster does not discard the only in-memory copy of another draft.
1682
+ setEntries((previous) => Object.fromEntries(Object.entries(previous).filter(([, held]) => !sameDraft(held.value, held.saved))))
1454
1683
  return
1455
1684
  }
1456
1685
  await open(wanted, { force: true, discard: options !== null && typeof options === "object" && options.discard === true }, token)
@@ -1466,6 +1695,7 @@ try {
1466
1695
  const result = await fetchList()
1467
1696
  if (!ownsOperation(token)) return
1468
1697
  if (result !== null && result !== undefined && result.ok === true) {
1698
+ if (typeof result.version === "string" && result.version !== "") setRuntimeVersion(result.version)
1469
1699
  setList(Array.isArray(result.assistants) ? result.assistants : [])
1470
1700
  } else throw new Error(apiText(result, "msg.readFailed"))
1471
1701
  }
@@ -1494,6 +1724,149 @@ try {
1494
1724
  }
1495
1725
  }, [])
1496
1726
 
1727
+ react.useEffect(() => {
1728
+ updateOperation.current.alive = true
1729
+ const changed = () => {
1730
+ updateOperation.current.current = null
1731
+ setUpdateChecking(false)
1732
+ setUpdateResult(null)
1733
+ setUpdateNotice("")
1734
+ setUpdateRevision((value) => value + 1)
1735
+ }
1736
+ updateListeners.add(changed)
1737
+ return () => {
1738
+ updateListeners.delete(changed)
1739
+ updateOperation.current.alive = false
1740
+ updateOperation.current.current = null
1741
+ }
1742
+ }, [])
1743
+
1744
+ const hasNativeUpdates = (services) => typeof services.manager?.listBundles === "function" &&
1745
+ typeof services.manager?.listVersions === "function" && typeof services.manager?.updateBundle === "function"
1746
+ const ownsUpdateCheck = (token, services) => updateOperation.current.alive &&
1747
+ updateOperation.current.current === token && updateServices.revision === services.revision
1748
+ const checkUpdates = async () => {
1749
+ if (!updateOperation.current.alive || operation.current.current !== null || updateOperation.current.current !== null) return
1750
+ const services = updateServices
1751
+ if (!hasNativeUpdates(services) || !services.list || !services.versions) return
1752
+ const token = {}
1753
+ updateOperation.current.current = token
1754
+ setUpdateChecking(true)
1755
+ setUpdateResult(null)
1756
+ setUpdateNotice("")
1757
+ const show = (key, details) => setUpdateResult({ key, ...details, revision: services.revision })
1758
+ try {
1759
+ const inventory = await services.manager.listBundles()
1760
+ if (!ownsUpdateCheck(token, services)) return
1761
+ if (inventory?.ok !== true || !Array.isArray(inventory.value)) throw new Error("inventory")
1762
+ const bundle = inventory.value.find((item) => item?.name === UPDATE_PACKAGE)
1763
+ if (bundle === undefined) { show("update.notFound"); return }
1764
+ if (bundle.updatable !== true || bundle.installed !== true || bundle.availability !== "profile" ||
1765
+ bundle.official !== false || bundle.readOnlyReason !== undefined || typeof bundle.source !== "string" ||
1766
+ typeof bundle.version !== "string" || bundle.version === "") {
1767
+ show("update.managed", { installed: bundle.version }); return
1768
+ }
1769
+ const answer = await services.manager.listVersions(UPDATE_PACKAGE, {})
1770
+ if (!ownsUpdateCheck(token, services)) return
1771
+ if (answer?.ok !== true || answer.value === null || typeof answer.value !== "object") throw new Error("versions")
1772
+ const listing = answer.value
1773
+ if (listing.status === "refused") {
1774
+ show(listing.problem === "network" ? "update.network" : listing.problem === "not-updatable" ? "update.managed" : "update.refused")
1775
+ return
1776
+ }
1777
+ if (listing.status !== "listed" || typeof listing.installed !== "string" || listing.installed !== bundle.version ||
1778
+ !(listing.registry === null || typeof listing.registry === "string") || !Array.isArray(listing.attempts) ||
1779
+ !Number.isInteger(listing.omitted) || listing.omitted < 0 || !Array.isArray(listing.versions) ||
1780
+ !listing.versions.every((item) => item !== null && typeof item === "object" && typeof item.version === "string" &&
1781
+ item.version !== "" && typeof item.bundle === "boolean" && typeof item.prerelease === "boolean" && Array.isArray(item.tags))) {
1782
+ throw new Error("incomplete listing")
1783
+ }
1784
+ // Match the official default candidate and trust the Host's semver ordering.
1785
+ const candidate = listing.versions.find((item) => item.bundle && item.incompatible === undefined && item.deprecated === undefined && !item.prerelease)
1786
+ show(candidate ? "update.available" : listing.omitted > 0 ? "update.partial" : listing.versions.length > 0 ? "update.filtered" : "update.current", {
1787
+ candidate: candidate?.version, installed: listing.installed, omitted: listing.omitted,
1788
+ })
1789
+ } catch {
1790
+ if (ownsUpdateCheck(token, services)) show("update.failed")
1791
+ } finally {
1792
+ if (ownsUpdateCheck(token, services)) {
1793
+ updateOperation.current.current = null
1794
+ setUpdateChecking(false)
1795
+ }
1796
+ }
1797
+ }
1798
+
1799
+ /** Latest refs also guard a click before React commits the last keystroke. */
1800
+ const updateNavigationBlock = () => {
1801
+ if (operation.current.current !== null || busy || updateOperation.current.current !== null) return "update.finishOperation"
1802
+ if (navigationState.current.syncNeeded || navigationState.current.refreshNeeded !== null) return "update.finishRecovery"
1803
+ if (navigationState.current.newName !== "" || Object.values(heldEntries.current).some((held) => !sameDraft(held.value, held.saved))) return "update.saveDrafts"
1804
+ return ""
1805
+ }
1806
+ const guardUpdateNavigation = () => {
1807
+ if (!updateOperation.current.alive) return false
1808
+ const block = updateNavigationBlock()
1809
+ if (block !== "") { setUpdateNotice(block); return false }
1810
+ return true
1811
+ }
1812
+ const openPluginDetails = () => {
1813
+ if (!guardUpdateNavigation()) return
1814
+ const services = updateServices
1815
+ if (((hasNativeUpdates(services) || services.accessKnown) && !services.list) || typeof services.navigation?.openBundle !== "function" ||
1816
+ (updateResult?.candidate && (updateResult.revision !== services.revision || !services.update))) {
1817
+ setUpdateNotice("update.permission"); return
1818
+ }
1819
+ try { services.navigation.openBundle(UPDATE_PACKAGE) }
1820
+ catch { setUpdateNotice("update.navigationFailed") }
1821
+ }
1822
+
1823
+ const orphanDrafts = () => {
1824
+ if (!Array.isArray(heldRoster.current)) return []
1825
+ const visible = new Set(heldRoster.current.map((item) => item.id))
1826
+ return Object.entries(heldEntries.current).filter(([id, held]) => !visible.has(id) && !sameDraft(held.value, held.saved))
1827
+ }
1828
+ const closeOrphanConfirmation = () => {
1829
+ orphanConfirmation.current = null
1830
+ orphanAcknowledgement.current = false
1831
+ setOrphanAcknowledged(false)
1832
+ setOrphanDialog(null)
1833
+ }
1834
+ const orphanActionBlock = () => operation.current.current !== null || busy || updateOperation.current.current !== null
1835
+ ? "update.finishOperation"
1836
+ : navigationState.current.syncNeeded || navigationState.current.refreshNeeded !== null ? "update.finishRecovery" : ""
1837
+ const orphanSnapshotCurrent = (snapshot) => heldEntries.current === snapshot.entries && heldRoster.current === snapshot.roster &&
1838
+ navigationState.current.newName === snapshot.newName && orphanDrafts().some(([id]) => id === snapshot.id)
1839
+ const discardOrphan = (snapshot) => {
1840
+ if (!updateOperation.current.alive || orphanConfirmation.current !== snapshot || !orphanAcknowledgement.current) return
1841
+ const blocked = orphanActionBlock()
1842
+ if (blocked || !orphanSnapshotCurrent(snapshot)) {
1843
+ closeOrphanConfirmation()
1844
+ setUpdateNotice(blocked || "update.orphanChanged")
1845
+ return
1846
+ }
1847
+ setEntries((previous) => { const next = { ...previous }; delete next[snapshot.id]; return next })
1848
+ closeOrphanConfirmation()
1849
+ setOrphanView("")
1850
+ setUpdateNotice("update.orphanDiscarded")
1851
+ }
1852
+ const askDiscardOrphan = (id, shown) => {
1853
+ if (!updateOperation.current.alive) return
1854
+ const snapshot = { ...shown, id }
1855
+ const blocked = orphanActionBlock()
1856
+ if (blocked || !orphanSnapshotCurrent(snapshot)) { setUpdateNotice(blocked || "update.orphanChanged"); return }
1857
+ const value = snapshot.entries[id].value
1858
+ snapshot.name = value.name || id
1859
+ orphanConfirmation.current = snapshot
1860
+ orphanAcknowledgement.current = false
1861
+ setOrphanAcknowledged(false)
1862
+ if (renderable(A.RiskConfirmation)) setOrphanDialog(snapshot)
1863
+ else if (typeof window !== "undefined" && typeof window.confirm === "function" &&
1864
+ window.confirm(fillPlaceholders(t("update.orphanConfirm"), { name: snapshot.name, id }))) {
1865
+ orphanAcknowledgement.current = true
1866
+ discardOrphan(snapshot)
1867
+ } else closeOrphanConfirmation()
1868
+ }
1869
+
1497
1870
  /** Change one field of the selected assistant's draft. */
1498
1871
  const update = (patch, options) => {
1499
1872
  // ★ 忙碌期间**不接受**对草稿的修改(issue #6)。
@@ -1555,13 +1928,17 @@ try {
1555
1928
  if (!ownsOperation(token)) return
1556
1929
  showResult(result, from === undefined ? "msg.created" : "msg.duplicated", "msg.createFailed")
1557
1930
  if (result?.ok === true || result?.persisted === true) {
1558
- setNewName("")
1931
+ if (from === undefined) setNewName("")
1932
+ setFocusCreated(result.id)
1559
1933
  await refreshAfterWrite(result, from === undefined ? "msg.created" : "msg.duplicated", result.id, token, () => reload(result.id, undefined, token))
1560
1934
  }
1561
1935
  })
1562
1936
  }
1563
1937
 
1564
- const duplicate = () => create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
1938
+ const duplicate = () => {
1939
+ if (dirty && (typeof window.confirm !== "function" || !window.confirm(t("assistant.duplicateSavedConfirm")))) return
1940
+ return create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
1941
+ }
1565
1942
 
1566
1943
  /**
1567
1944
  * Move the selected assistant one slot in the picker order.
@@ -1571,10 +1948,18 @@ try {
1571
1948
  */
1572
1949
  const move = async (direction) => {
1573
1950
  return runOperation("status.reordering", "msg.reorderFailed", async (token) => {
1574
- const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
1951
+ const baseRevisions = Object.fromEntries(Object.entries(heldEntries.current)
1952
+ .filter(([, held]) => typeof held.payload?.revision === "string")
1953
+ .map(([id, held]) => [id, held.payload.revision]))
1954
+ const result = await postJson(ROUTES.reorder, { id: draft.id, direction, baseRevisions })
1575
1955
  if (!ownsOperation(token)) return
1576
1956
  showResult(result, "msg.reordered", "msg.reorderFailed")
1577
1957
  if (result?.ok === true || result?.persisted === true) {
1958
+ setEntries((previous) => Object.fromEntries(Object.entries(previous).map(([id, held]) => {
1959
+ const revision = result.revisions?.[id]
1960
+ return [id, typeof revision === "string" && typeof baseRevisions[id] === "string" &&
1961
+ held.payload?.revision === baseRevisions[id] ? { ...held, payload: { ...held.payload, revision } } : held]
1962
+ })))
1578
1963
  await refreshAfterWrite(result, "msg.reordered", draft.id, token, () => reload(draft.id, undefined, token))
1579
1964
  }
1580
1965
  })
@@ -1587,16 +1972,21 @@ try {
1587
1972
  * 只改**草稿**,不写盘:这样"一键回到官方"既立刻可见,又可被「重新读取」撤销 ——
1588
1973
  * 与页面其余部分一样,一切改动都要点保存才落盘。
1589
1974
  */
1975
+ const allowPromptReplacement = () => {
1976
+ const held = heldEntries.current[selected]
1977
+ return held === undefined || sameDraft(held.value, held.saved) ||
1978
+ (typeof window.confirm === "function" && window.confirm(t("prompt.replaceConfirm")))
1979
+ }
1590
1980
  const resetPrompt = () => {
1591
1981
  const factory = typeof draft.factoryPrompt === "string" ? draft.factoryPrompt : ""
1592
- if (factory === "") return
1982
+ if (factory === "" || !allowPromptReplacement()) return
1593
1983
  update({ prompt: factory })
1594
1984
  }
1595
1985
 
1596
1986
  /** 把某个历史版本载入编辑器。与「恢复出厂」同一条纪律:只改草稿,保存前不落盘。 */
1597
1987
  const loadVersion = async () => {
1598
1988
  const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
1599
- if (picked === "" || draft.id === undefined) return
1989
+ if (picked === "" || draft.id === undefined || !allowPromptReplacement()) return
1600
1990
  return runOperation("status.reading", "msg.loadFailed", async (token) => {
1601
1991
  // 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
1602
1992
  const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
@@ -1651,6 +2041,7 @@ try {
1651
2041
  setStatus(t("msg.importTooLarge"))
1652
2042
  return
1653
2043
  }
2044
+ if (!allowPromptReplacement()) return
1654
2045
  return runOperation("status.importing", "msg.importFailed", async (token) => {
1655
2046
  const text = await new Promise((resolve, reject) => {
1656
2047
  const reader = new FileReader()
@@ -1713,7 +2104,7 @@ try {
1713
2104
  }
1714
2105
 
1715
2106
  const save = async () => {
1716
- if (draft === null) return
2107
+ if (draft === null || navigationState.current.refreshNeeded !== null || navigationState.current.syncNeeded) return
1717
2108
  return runOperation("status.saving", "msg.saveFailed", async (token) => {
1718
2109
  const savedHistory = entry?.saved?.history
1719
2110
  const newest = Array.isArray(savedHistory) && savedHistory.length > 0 ? savedHistory[0].n : undefined
@@ -1724,11 +2115,16 @@ try {
1724
2115
  prompt: draft.prompt,
1725
2116
  name: draft.name,
1726
2117
  description: draft.description,
1727
- ...(typeof newest === "number" ? { baseN: newest } : {}),
2118
+ ...(typeof entry?.payload?.revision === "string" ? { baseRevision: entry.payload.revision }
2119
+ : typeof newest === "number" ? { baseN: newest } : {}),
1728
2120
  })
1729
2121
  if (!ownsOperation(token)) return
1730
2122
  if (result?.synchronized === false) setSyncNeeded(true)
1731
2123
  else if (result?.synchronized === true) setSyncNeeded(false)
2124
+ if ((result?.ok === true || result?.persisted === true) && typeof result.revision === "string") {
2125
+ setEntries((previous) => ({ ...previous, [draft.id]: { ...previous[draft.id],
2126
+ payload: { ...previous[draft.id].payload, revision: result.revision }, saved: draft } }))
2127
+ }
1732
2128
  if (result !== null && result !== undefined && result.ok === true) {
1733
2129
  setFailed(false)
1734
2130
  const compositionChanged =
@@ -1803,7 +2199,7 @@ try {
1803
2199
  ]
1804
2200
  const createGroup = react.createElement(
1805
2201
  "section",
1806
- { className: "cpfe-create" },
2202
+ { className: "cpfe-create", ref: createSection },
1807
2203
  react.createElement("h2", { className: "cpfe-h" }, t("create.heading")),
1808
2204
  react.createElement(SectionHint, { text: t("posture.hint") }),
1809
2205
  react.createElement(
@@ -1844,13 +2240,15 @@ try {
1844
2240
  "div",
1845
2241
  { className: "cpfe-newrow" },
1846
2242
  react.createElement(A.Input, {
2243
+ ref: createNameInput,
1847
2244
  className: "cpfe-newinput",
1848
2245
  value: newName,
1849
2246
  disabled: busy,
1850
2247
  placeholder: t("assistant.newPlaceholder"),
1851
2248
  "aria-label": t("assistant.newPlaceholder"),
1852
2249
  onKeyDown: (event) => {
1853
- if (event.key === "Enter" && busy !== true) create()
2250
+ if (event.key === "Enter" && !event.isComposing && !event.nativeEvent?.isComposing &&
2251
+ event.keyCode !== 229 && event.nativeEvent?.keyCode !== 229 && busy !== true) create()
1854
2252
  },
1855
2253
  onChange: (event) => {
1856
2254
  setNewName(event.target.value)
@@ -1873,6 +2271,65 @@ try {
1873
2271
  )
1874
2272
 
1875
2273
  // ── assistant list ────────────────────────────────────────────────────
2274
+ const nativeUpdates = hasNativeUpdates(updateServices)
2275
+ const updateAllowed = nativeUpdates && updateServices.list && updateServices.versions
2276
+ const checkedUpdate = updateResult?.revision === updateServices.revision ? updateResult : null
2277
+ const navigationBlock = updateNavigationBlock()
2278
+ const updateText = updateChecking ? t("update.checking") : checkedUpdate
2279
+ ? fillPlaceholders(t(checkedUpdate.key), { version: checkedUpdate.candidate })
2280
+ : !nativeUpdates ? t("update.unavailable") : !updateAllowed ? t("update.permission") : t("update.idle")
2281
+ const shownOrphans = orphanDrafts()
2282
+ const orphanSnapshot = { entries: heldEntries.current, roster: heldRoster.current, newName: navigationState.current.newName }
2283
+ const updateSection = react.createElement(
2284
+ "section", { className: "cpfe-update", "aria-label": t("update.heading"), "aria-busy": updateChecking },
2285
+ react.createElement("div", { className: "cpfe-update-meta" },
2286
+ react.createElement("h2", { className: "cpfe-h" }, t("update.heading")),
2287
+ react.createElement("p", { className: "cpfe-version" }, fillPlaceholders(t("update.running"), { version: runtimeVersion || t("update.unknown") })),
2288
+ react.createElement("p", { id: "cpfe-update-status", className: "cpfe-sub cpfe-update-note", role: "status", "aria-live": "polite", "aria-atomic": true }, updateText),
2289
+ checkedUpdate?.installed && checkedUpdate.installed !== runtimeVersion
2290
+ ? react.createElement("p", { className: "cpfe-sub cpfe-update-note" }, fillPlaceholders(t("update.disk"), { version: checkedUpdate.installed })) : null,
2291
+ checkedUpdate?.omitted > 0
2292
+ ? react.createElement("p", { className: "cpfe-sub" }, fillPlaceholders(t("update.omitted"), { count: checkedUpdate.omitted })) : null,
2293
+ checkedUpdate?.candidate && !updateServices.update ? react.createElement("p", { className: "cpfe-sub" }, t("update.updatePermission")) : null,
2294
+ updateNotice || navigationBlock ? react.createElement("p", { className: "cpfe-sub", role: "status" }, t(updateNotice || navigationBlock)) : null,
2295
+ shownOrphans.length > 0 ? react.createElement("div", { className: "cpfe-warns cpfe-update-note" },
2296
+ react.createElement("p", { className: "cpfe-sub" }, t("update.orphanHint")),
2297
+ ...shownOrphans.map(([id, held]) => react.createElement("div", { key: id, className: "cpfe-node" },
2298
+ react.createElement("span", { className: "cpfe-row-head" }, fillPlaceholders(t("update.orphanName"), { name: held.value.name || id, id })),
2299
+ react.createElement(A.Button, { variant: "ghost", className: "cpfe-disclosure", "aria-expanded": orphanView === id,
2300
+ onClick: () => setOrphanView(orphanView === id ? "" : id) }, t(orphanView === id ? "update.orphanHide" : "update.orphanView")),
2301
+ orphanView === id ? react.createElement("div", { className: "cpfe-node" },
2302
+ react.createElement("p", { className: "cpfe-sub" }, t("update.orphanCopy")),
2303
+ react.createElement("textarea", { className: "cpfe-editor cpfe-orphan-editor", readOnly: true, spellCheck: false,
2304
+ "aria-label": fillPlaceholders(t("update.orphanName"), { name: held.value.name || id, id }),
2305
+ value: JSON.stringify({ id, name: held.value.name, description: held.value.description, mode: held.value.mode, overrides: held.value.overrides, prompt: held.value.prompt }, null, 2) }),
2306
+ react.createElement(A.Button, { variant: "outline", className: "cpfe-disclosure", disabled: orphanActionBlock() !== "",
2307
+ onClick: () => askDiscardOrphan(id, orphanSnapshot) }, t("update.orphanDiscard")),
2308
+ ) : null,
2309
+ )),
2310
+ ) : null,
2311
+ ),
2312
+ react.createElement("div", { className: "cpfe-update-actions" },
2313
+ react.createElement(A.Button, {
2314
+ variant: "outline", disabled: busy || updateChecking || !updateAllowed,
2315
+ "aria-describedby": "cpfe-update-status", onClick: checkUpdates,
2316
+ }, t(updateChecking ? "update.checking" : "update.check")),
2317
+ react.createElement(A.Button, {
2318
+ variant: "outline",
2319
+ disabled: navigationBlock !== "" || ((nativeUpdates || updateServices.accessKnown) && !updateServices.list) || typeof updateServices.navigation?.openBundle !== "function" ||
2320
+ (!!checkedUpdate?.candidate && !updateServices.update),
2321
+ title: navigationBlock ? t(navigationBlock) : undefined,
2322
+ onClick: openPluginDetails,
2323
+ }, t(checkedUpdate?.candidate ? "update.inPlugins" : "update.details")),
2324
+ react.createElement("a", {
2325
+ className: "cpfe-update-guide",
2326
+ href: "https://github.com/BOWLUNA/dsh-custom-mode/blob/main/README" + (activeLanguage(localeRef) === "zh" ? ".zh" : "") + ".md#plugin-updates",
2327
+ target: "_blank", rel: "noopener noreferrer", "aria-disabled": navigationBlock !== "",
2328
+ onClick: (event) => { if (!guardUpdateNavigation()) event.preventDefault() },
2329
+ }, t("update.guide")),
2330
+ ),
2331
+ )
2332
+
1876
2333
  const selectedAssistantLabel = (assistants.find((item) => item.id === selected) || {}).name || selected || t("assistant.heading")
1877
2334
  const pickedHistoryEntry = draft === null || draft.historyPick === "" ? undefined : draft.history.find((item) => String(item.n) === draft.historyPick)
1878
2335
  const historySelectionLabel = pickedHistoryEntry === undefined
@@ -1881,7 +2338,9 @@ try {
1881
2338
  const assistantList = react.createElement(
1882
2339
  "section",
1883
2340
  null,
1884
- react.createElement("h2", { className: "cpfe-h" }, t("assistant.heading")),
2341
+ react.createElement("div", { className: "cpfe-sec-head" },
2342
+ react.createElement("h2", { className: "cpfe-h" }, t("assistant.heading")),
2343
+ react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: focusCreate, "data-create-entry": true }, t("btn.newAssistant"))),
1885
2344
  react.createElement(SectionHint, { text: t("assistant.hint") }),
1886
2345
  list === null
1887
2346
  ? react.createElement(
@@ -2295,6 +2754,7 @@ try {
2295
2754
  // `cpfe-busy` 是 issue #6 的兜底:面板里**所有**会改草稿的控件在忙碌期间一律不可交互。
2296
2755
  // 逐个控件加 `disabled` 会漏(本轮就漏了行开关与描述框),一个根类 + 一条 CSS 反而漏不掉。
2297
2756
  { className: busy === true ? "cpfe cpfe-busy" : "cpfe", "aria-busy": busy === true },
2757
+ updateSection,
2298
2758
  assistantList,
2299
2759
  editorReady ? null : react.createElement("div", { className: "cpfe-bar" }, ...feedback),
2300
2760
  // 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
@@ -2352,7 +2812,7 @@ try {
2352
2812
  editorReady
2353
2813
  ? react.createElement(
2354
2814
  "section",
2355
- null,
2815
+ { ref: editorSection, tabIndex: -1, "data-assistant-editor": true },
2356
2816
  react.createElement("h2", { className: "cpfe-h" }, t("editor.heading")),
2357
2817
  react.createElement(SectionHint, { text: fillPlaceholders(t("editor.hint"), { name: draft.name || draft.id }) }),
2358
2818
  react.createElement(
@@ -2367,7 +2827,7 @@ try {
2367
2827
  {
2368
2828
  variant: "primary",
2369
2829
  icon: A.IconCheckOutline14 === undefined ? null : react.createElement(A.IconCheckOutline14),
2370
- disabled: busy || syncNeeded || editorReady === false || dirty === false,
2830
+ disabled: busy || syncNeeded || refreshNeeded !== null || editorReady === false || dirty === false,
2371
2831
  onClick: save,
2372
2832
  },
2373
2833
  pending === "status.saving" ? t("btn.saving") : t("btn.save"),
@@ -2391,16 +2851,6 @@ try {
2391
2851
  ...feedback,
2392
2852
  ),
2393
2853
  ...(editorReady ? editorSections.filter((section) => section.key === "name") : []),
2394
- editorReady
2395
- ? react.createElement(
2396
- "p",
2397
- {
2398
- className: "cpfe-version",
2399
- title: String(payload.compositionPath ?? "") + "\n" + t("meta.versionHint"),
2400
- },
2401
- "v" + String(payload.version ?? "?"),
2402
- )
2403
- : null,
2404
2854
  ...(editorReady ? editorSections.filter((section) => section.key === "mode") : []),
2405
2855
  editorReady
2406
2856
  ? react.createElement(
@@ -2440,6 +2890,21 @@ try {
2440
2890
  createGroup,
2441
2891
  // The confirmation is a portal: rendering it here keeps every piece of this page's
2442
2892
  // state in one component.
2893
+ renderable(A.RiskConfirmation) && orphanDialog !== null
2894
+ ? react.createElement(A.RiskConfirmation, {
2895
+ open: true, title: t("update.orphanDiscard"),
2896
+ description: fillPlaceholders(t("update.orphanConfirm"), { name: orphanDialog.name, id: orphanDialog.id }),
2897
+ acknowledgeLabel: t("update.orphanAcknowledge"), acknowledged: orphanAcknowledged,
2898
+ onAcknowledgedChange: (value) => {
2899
+ if (!updateOperation.current.alive || orphanConfirmation.current !== orphanDialog) return
2900
+ orphanAcknowledgement.current = value === true
2901
+ setOrphanAcknowledged(value === true)
2902
+ },
2903
+ cancelLabel: t("btn.cancel"), closeLabel: t("btn.cancel"), confirmLabel: t("update.orphanDiscard"),
2904
+ disabled: orphanActionBlock() !== "", onCancel: () => { if (updateOperation.current.alive && orphanConfirmation.current === orphanDialog) closeOrphanConfirmation() },
2905
+ onConfirm: () => discardOrphan(orphanDialog),
2906
+ })
2907
+ : null,
2443
2908
  renderable(A.RiskConfirmation) && editorReady
2444
2909
  ? react.createElement(A.RiskConfirmation, {
2445
2910
  open: deleteOpen,
@@ -2497,6 +2962,9 @@ try {
2497
2962
  console.warn("dsh-custom-mode: slots service unavailable; settings page not registered")
2498
2963
  return
2499
2964
  }
2965
+ const draftMemory = mountDraftProtection(ctx)
2966
+ try { mountUpdateServices(ctx) }
2967
+ catch { console.warn("dsh-custom-mode: optional native update services unavailable") }
2500
2968
  slots.inject("settings.section", () => {
2501
2969
  // `locale: NS` is the shipped contract: the shell then hands the
2502
2970
  // component a bound `t` in props.
@@ -2512,7 +2980,7 @@ try {
2512
2980
  label: () => translate("nav", undefined, navT === null ? undefined : navT),
2513
2981
  locale: NS,
2514
2982
  },
2515
- CustomModeSection,
2983
+ function CustomModeSettings(props) { return CustomModeSection({ ...props, draftMemory }) },
2516
2984
  )
2517
2985
  })
2518
2986
  } catch (error) {