@songsid/agend 2.3.1 → 2.3.2-beta.10

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.
Files changed (338) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/dist/agent-cli.js +21 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +5 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/antigravity-usage-scan.d.ts +58 -0
  7. package/dist/antigravity-usage-scan.js +257 -0
  8. package/dist/antigravity-usage-scan.js.map +1 -0
  9. package/dist/antigravity-usage-worker.d.ts +1 -0
  10. package/dist/antigravity-usage-worker.js +10 -0
  11. package/dist/antigravity-usage-worker.js.map +1 -0
  12. package/dist/auth-api.js +4 -0
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/authority/authority-state.d.ts +4 -0
  15. package/dist/authority/authority-state.js +16 -10
  16. package/dist/authority/authority-state.js.map +1 -1
  17. package/dist/authority/session-authorizations.d.ts +7 -1
  18. package/dist/authority/session-authorizations.js +6 -4
  19. package/dist/authority/session-authorizations.js.map +1 -1
  20. package/dist/authority/web-authority.d.ts +19 -6
  21. package/dist/authority/web-authority.js +22 -8
  22. package/dist/authority/web-authority.js.map +1 -1
  23. package/dist/backend/kiro-identity.d.ts +64 -0
  24. package/dist/backend/kiro-identity.js +101 -0
  25. package/dist/backend/kiro-identity.js.map +1 -1
  26. package/dist/backend/kiro-retired-model.d.ts +62 -0
  27. package/dist/backend/kiro-retired-model.js +160 -0
  28. package/dist/backend/kiro-retired-model.js.map +1 -0
  29. package/dist/backend/kiro.d.ts +71 -2
  30. package/dist/backend/kiro.js +352 -11
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +59 -0
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/backend-signin-guide.d.ts +1 -0
  35. package/dist/backend-signin-guide.js +9 -0
  36. package/dist/backend-signin-guide.js.map +1 -0
  37. package/dist/backend-signin-pending.d.ts +10 -0
  38. package/dist/backend-signin-pending.js +44 -0
  39. package/dist/backend-signin-pending.js.map +1 -0
  40. package/dist/browser-backend-signins.d.ts +56 -0
  41. package/dist/browser-backend-signins.js +189 -0
  42. package/dist/browser-backend-signins.js.map +1 -0
  43. package/dist/cache-ledger.d.ts +38 -2
  44. package/dist/cache-ledger.js +133 -8
  45. package/dist/cache-ledger.js.map +1 -1
  46. package/dist/cache-prices.d.ts +2 -1
  47. package/dist/cache-prices.js +5 -2
  48. package/dist/cache-prices.js.map +1 -1
  49. package/dist/cache-service.d.ts +68 -0
  50. package/dist/cache-service.js +355 -13
  51. package/dist/cache-service.js.map +1 -1
  52. package/dist/channel/adapters/discord.d.ts +13 -0
  53. package/dist/channel/adapters/discord.js +103 -16
  54. package/dist/channel/adapters/discord.js.map +1 -1
  55. package/dist/channel/mcp-tools.js +34 -7
  56. package/dist/channel/mcp-tools.js.map +1 -1
  57. package/dist/channel/tool-router.js +2 -1
  58. package/dist/channel/tool-router.js.map +1 -1
  59. package/dist/channel/types.d.ts +11 -0
  60. package/dist/classic-channel-manager.d.ts +11 -0
  61. package/dist/classic-channel-manager.js +39 -1
  62. package/dist/classic-channel-manager.js.map +1 -1
  63. package/dist/cli.js +21 -2
  64. package/dist/cli.js.map +1 -1
  65. package/dist/config-validator.d.ts +7 -1
  66. package/dist/config-validator.js +92 -12
  67. package/dist/config-validator.js.map +1 -1
  68. package/dist/config.d.ts +19 -0
  69. package/dist/config.js +31 -1
  70. package/dist/config.js.map +1 -1
  71. package/dist/cpu-quota.d.ts +23 -0
  72. package/dist/cpu-quota.js +86 -0
  73. package/dist/cpu-quota.js.map +1 -0
  74. package/dist/daemon.d.ts +57 -3
  75. package/dist/daemon.js +330 -50
  76. package/dist/daemon.js.map +1 -1
  77. package/dist/dashboard-permissions.d.ts +20 -6
  78. package/dist/dashboard-permissions.js +58 -9
  79. package/dist/dashboard-permissions.js.map +1 -1
  80. package/dist/delivery-outbox.d.ts +12 -1
  81. package/dist/delivery-outbox.js +67 -15
  82. package/dist/delivery-outbox.js.map +1 -1
  83. package/dist/doctor.d.ts +3 -0
  84. package/dist/doctor.js +11 -1
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/event-log.d.ts +5 -0
  87. package/dist/event-log.js +13 -0
  88. package/dist/event-log.js.map +1 -1
  89. package/dist/fleet-manager.d.ts +212 -0
  90. package/dist/fleet-manager.js +914 -111
  91. package/dist/fleet-manager.js.map +1 -1
  92. package/dist/fleet-process-handlers.d.ts +1 -0
  93. package/dist/fleet-process-handlers.js +9 -1
  94. package/dist/fleet-process-handlers.js.map +1 -1
  95. package/dist/forged-envelope-scan.d.ts +9 -0
  96. package/dist/forged-envelope-scan.js +30 -5
  97. package/dist/forged-envelope-scan.js.map +1 -1
  98. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
  99. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
  100. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +28 -5
  101. package/dist/general-knowledge/skills/web-appearance/SKILL.md +31 -8
  102. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +138 -59
  103. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +145 -61
  104. package/dist/guest-seen-people.d.ts +38 -0
  105. package/dist/guest-seen-people.js +78 -0
  106. package/dist/guest-seen-people.js.map +1 -0
  107. package/dist/instance-config-impact.js +14 -6
  108. package/dist/instance-config-impact.js.map +1 -1
  109. package/dist/instance-lifecycle.d.ts +20 -0
  110. package/dist/instance-lifecycle.js +71 -13
  111. package/dist/instance-lifecycle.js.map +1 -1
  112. package/dist/kiro-db-reader.d.ts +5 -0
  113. package/dist/kiro-db-reader.js +18 -0
  114. package/dist/kiro-db-reader.js.map +1 -1
  115. package/dist/kiro-engine-status.d.ts +2 -0
  116. package/dist/kiro-engine-status.js +9 -0
  117. package/dist/kiro-engine-status.js.map +1 -1
  118. package/dist/kiro-migrate.d.ts +49 -0
  119. package/dist/kiro-migrate.js +105 -0
  120. package/dist/kiro-migrate.js.map +1 -0
  121. package/dist/kiro-transcript-lane.d.ts +2 -0
  122. package/dist/kiro-transcript-lane.js.map +1 -1
  123. package/dist/kiro-transcript-worker.js +1 -0
  124. package/dist/kiro-transcript-worker.js.map +1 -1
  125. package/dist/kiro-tui-forensic.d.ts +7 -0
  126. package/dist/kiro-tui-forensic.js +110 -0
  127. package/dist/kiro-tui-forensic.js.map +1 -0
  128. package/dist/kiro-usage-run.d.ts +7 -0
  129. package/dist/kiro-usage-run.js +39 -0
  130. package/dist/kiro-usage-run.js.map +1 -0
  131. package/dist/kiro-usage-scan.d.ts +49 -0
  132. package/dist/kiro-usage-scan.js +223 -0
  133. package/dist/kiro-usage-scan.js.map +1 -0
  134. package/dist/kiro-usage-worker.d.ts +1 -0
  135. package/dist/kiro-usage-worker.js +10 -0
  136. package/dist/kiro-usage-worker.js.map +1 -0
  137. package/dist/kiro-usage.d.ts +58 -0
  138. package/dist/kiro-usage.js +310 -0
  139. package/dist/kiro-usage.js.map +1 -0
  140. package/dist/locale.js +120 -12
  141. package/dist/locale.js.map +1 -1
  142. package/dist/log-service.js +1 -1
  143. package/dist/log-service.js.map +1 -1
  144. package/dist/login-url.d.ts +4 -0
  145. package/dist/login-url.js +22 -0
  146. package/dist/login-url.js.map +1 -0
  147. package/dist/mac-auto-restore.d.ts +117 -0
  148. package/dist/mac-auto-restore.js +294 -0
  149. package/dist/mac-auto-restore.js.map +1 -0
  150. package/dist/needs-you-hub.d.ts +2 -1
  151. package/dist/needs-you-hub.js +1 -1
  152. package/dist/needs-you-hub.js.map +1 -1
  153. package/dist/needs-you.d.ts +8 -2
  154. package/dist/needs-you.js +6 -0
  155. package/dist/needs-you.js.map +1 -1
  156. package/dist/notice-delivery.d.ts +16 -0
  157. package/dist/notice-delivery.js +35 -0
  158. package/dist/notice-delivery.js.map +1 -0
  159. package/dist/office-share-page.d.ts +16 -0
  160. package/dist/office-share-page.js +85 -0
  161. package/dist/office-share-page.js.map +1 -0
  162. package/dist/office-share-service.d.ts +91 -0
  163. package/dist/office-share-service.js +176 -0
  164. package/dist/office-share-service.js.map +1 -0
  165. package/dist/office-share.d.ts +258 -0
  166. package/dist/office-share.js +549 -0
  167. package/dist/office-share.js.map +1 -0
  168. package/dist/opencode-usage-scan.d.ts +28 -0
  169. package/dist/opencode-usage-scan.js +92 -0
  170. package/dist/opencode-usage-scan.js.map +1 -0
  171. package/dist/opencode-usage-worker.d.ts +1 -0
  172. package/dist/opencode-usage-worker.js +10 -0
  173. package/dist/opencode-usage-worker.js.map +1 -0
  174. package/dist/outbound-handlers.js +22 -2
  175. package/dist/outbound-handlers.js.map +1 -1
  176. package/dist/outbound-schemas.d.ts +28 -2
  177. package/dist/outbound-schemas.js +29 -2
  178. package/dist/outbound-schemas.js.map +1 -1
  179. package/dist/public-web-gateway.js +16 -2
  180. package/dist/public-web-gateway.js.map +1 -1
  181. package/dist/quickstart-api.js +13 -2
  182. package/dist/quickstart-api.js.map +1 -1
  183. package/dist/quickstart.js +2 -1
  184. package/dist/quickstart.js.map +1 -1
  185. package/dist/remote-card-answer.d.ts +83 -0
  186. package/dist/remote-card-answer.js +208 -0
  187. package/dist/remote-card-answer.js.map +1 -0
  188. package/dist/reply-triggers.d.ts +2 -0
  189. package/dist/reply-triggers.js +3 -0
  190. package/dist/reply-triggers.js.map +1 -0
  191. package/dist/settings-api.d.ts +28 -0
  192. package/dist/settings-api.js +148 -17
  193. package/dist/settings-api.js.map +1 -1
  194. package/dist/settings-change.js +54 -17
  195. package/dist/settings-change.js.map +1 -1
  196. package/dist/settings-effect.js +12 -3
  197. package/dist/settings-effect.js.map +1 -1
  198. package/dist/settings-instance-patch.js +4 -1
  199. package/dist/settings-instance-patch.js.map +1 -1
  200. package/dist/setup-form.d.ts +1 -1
  201. package/dist/setup-form.js +55 -6
  202. package/dist/setup-form.js.map +1 -1
  203. package/dist/setup-host.d.ts +9 -0
  204. package/dist/setup-host.js +127 -6
  205. package/dist/setup-host.js.map +1 -1
  206. package/dist/setup-signin-assets.d.ts +2 -0
  207. package/dist/setup-signin-assets.js +6 -0
  208. package/dist/setup-signin-assets.js.map +1 -0
  209. package/dist/setup-wizard.js +2 -0
  210. package/dist/setup-wizard.js.map +1 -1
  211. package/dist/start-attempt.d.ts +9 -5
  212. package/dist/start-attempt.js +27 -8
  213. package/dist/start-attempt.js.map +1 -1
  214. package/dist/start-failure-chat.d.ts +4 -0
  215. package/dist/start-failure-chat.js +23 -6
  216. package/dist/start-failure-chat.js.map +1 -1
  217. package/dist/start-failure-notice.d.ts +4 -0
  218. package/dist/start-failure-notice.js +8 -0
  219. package/dist/start-failure-notice.js.map +1 -1
  220. package/dist/start-failure.d.ts +25 -0
  221. package/dist/start-failure.js +75 -2
  222. package/dist/start-failure.js.map +1 -1
  223. package/dist/structured-signin-transport.d.ts +8 -0
  224. package/dist/structured-signin-transport.js +79 -0
  225. package/dist/structured-signin-transport.js.map +1 -0
  226. package/dist/structured-signin.d.ts +131 -0
  227. package/dist/structured-signin.js +388 -0
  228. package/dist/structured-signin.js.map +1 -0
  229. package/dist/tool-permissions.js +12 -0
  230. package/dist/tool-permissions.js.map +1 -1
  231. package/dist/topic-commands.js +6 -2
  232. package/dist/topic-commands.js.map +1 -1
  233. package/dist/transcript-sources.d.ts +26 -2
  234. package/dist/transcript-sources.js +103 -40
  235. package/dist/transcript-sources.js.map +1 -1
  236. package/dist/turn-reply-guard.d.ts +25 -0
  237. package/dist/turn-reply-guard.js +111 -12
  238. package/dist/turn-reply-guard.js.map +1 -1
  239. package/dist/types.d.ts +46 -9
  240. package/dist/ui/backend-signin.js +68 -12
  241. package/dist/ui/chat-ambient.js +1 -1
  242. package/dist/ui/chat-effects.js +4 -4
  243. package/dist/ui/chat-render.js +5 -2
  244. package/dist/ui/chat-strings.js +4 -4
  245. package/dist/ui/chat-thread.js +2 -2
  246. package/dist/ui/first-run.js +2 -2
  247. package/dist/ui/fleet-canvas-view.js +114 -0
  248. package/dist/ui/fleet-canvas.js +15 -77
  249. package/dist/ui/fleet-office-editor.js +94 -14
  250. package/dist/ui/fleet-office-layout.js +235 -0
  251. package/dist/ui/fleet-office-model.js +12 -6
  252. package/dist/ui/fleet-office-overview.js +167 -0
  253. package/dist/ui/fleet-office-pack.d.ts +44 -0
  254. package/dist/ui/fleet-office-pack.js +352 -37
  255. package/dist/ui/fleet-office-skin.js +220 -36
  256. package/dist/ui/fleet-office-store.js +112 -7
  257. package/dist/ui/fleet-office-view.js +155 -0
  258. package/dist/ui/fleet-office-zones.js +264 -42
  259. package/dist/ui/fleet-office.js +957 -160
  260. package/dist/ui/fleet-usage.js +26 -21
  261. package/dist/ui/office-share-dialog.js +145 -0
  262. package/dist/ui/office-share-page.js +164 -0
  263. package/dist/ui/panel-chat.js +9 -2
  264. package/dist/ui/panel-details.js +2 -2
  265. package/dist/ui/panel-fleet.js +11 -77
  266. package/dist/ui/panel-log.js +4 -4
  267. package/dist/ui/panel-needs.js +4 -4
  268. package/dist/ui/panel-settings.js +608 -167
  269. package/dist/ui/search-dialog.js +26 -7
  270. package/dist/ui/settings-appearance.js +82 -29
  271. package/dist/ui/settings-apply.js +42 -7
  272. package/dist/ui/settings-dialogs.js +76 -23
  273. package/dist/ui/settings-draft.js +51 -0
  274. package/dist/ui/settings-guests.js +255 -79
  275. package/dist/ui/settings-learn.js +2 -2
  276. package/dist/ui/settings-model.js +16 -6
  277. package/dist/ui/settings-registry.js +146 -0
  278. package/dist/ui/settings-strings.js +116 -44
  279. package/dist/ui/settings-web-authority.js +34 -23
  280. package/dist/ui/shared/app-i18n.js +18 -2
  281. package/dist/ui/shared/app-nav.js +10 -5
  282. package/dist/ui/shared/app-prefs.js +80 -2
  283. package/dist/ui/shared/app-route.js +39 -5
  284. package/dist/ui/shared/app-shell.js +73 -10
  285. package/dist/ui/shared/app-stream.js +3 -1
  286. package/dist/ui/shared/app.css +307 -17
  287. package/dist/ui/shared/app.js +3 -0
  288. package/dist/ui/shared/effect-stock-trust.js +1 -1
  289. package/dist/ui/shared/identity-color.js +4 -4
  290. package/dist/ui/shared/office/cozy.png +0 -0
  291. package/dist/ui/shared/side-panel.js +95 -0
  292. package/dist/ui/shared/signin.js +2 -2
  293. package/dist/ui/shared/ui-dialog.js +6 -1
  294. package/dist/ui/shared/ui-icons.js +2 -0
  295. package/dist/ui/shared/web-authorize.js +13 -12
  296. package/dist/ui/structured-signin.js +115 -0
  297. package/dist/usage-report.d.ts +20 -0
  298. package/dist/usage-report.js +54 -17
  299. package/dist/usage-report.js.map +1 -1
  300. package/dist/usage-rollup.d.ts +9 -1
  301. package/dist/usage-rollup.js +20 -4
  302. package/dist/usage-rollup.js.map +1 -1
  303. package/dist/web-api.d.ts +26 -2
  304. package/dist/web-api.js +361 -5
  305. package/dist/web-api.js.map +1 -1
  306. package/dist/web-auth.js +8 -2
  307. package/dist/web-auth.js.map +1 -1
  308. package/dist/web-backend-login.d.ts +1 -7
  309. package/dist/web-backend-login.js +2 -24
  310. package/dist/web-backend-login.js.map +1 -1
  311. package/dist/web-experimental.d.ts +1 -1
  312. package/dist/web-experimental.js +1 -1
  313. package/dist/web-experimental.js.map +1 -1
  314. package/dist/web-mod-agent.js +2 -2
  315. package/dist/web-mod-agent.js.map +1 -1
  316. package/dist/web-office-acts.d.ts +173 -0
  317. package/dist/web-office-acts.js +285 -0
  318. package/dist/web-office-acts.js.map +1 -0
  319. package/dist/web-office-skins.d.ts +109 -0
  320. package/dist/web-office-skins.js +299 -0
  321. package/dist/web-office-skins.js.map +1 -0
  322. package/dist/web-office-store.d.ts +8 -2
  323. package/dist/web-office-store.js +77 -14
  324. package/dist/web-office-store.js.map +1 -1
  325. package/dist/web-settings-patch.d.ts +8 -1
  326. package/dist/web-settings-patch.js +65 -5
  327. package/dist/web-settings-patch.js.map +1 -1
  328. package/dist/web-shell-routes.d.ts +4 -2
  329. package/dist/web-shell-routes.js +17 -7
  330. package/dist/web-shell-routes.js.map +1 -1
  331. package/dist/working-recovery.d.ts +104 -0
  332. package/dist/working-recovery.js +298 -0
  333. package/dist/working-recovery.js.map +1 -0
  334. package/dist/working-state-journal.d.ts +5 -1
  335. package/dist/working-state-journal.js +31 -3
  336. package/dist/working-state-journal.js.map +1 -1
  337. package/package.json +3 -2
  338. package/templates/windows-shortcuts.ps1 +155 -140
@@ -14,7 +14,9 @@ import { appStore, useStore } from "/assets/app-store.js";
14
14
  import { useLease } from "/assets/app-ctx.js";
15
15
  import { DevicePrefs, PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
16
16
  import { navigate, navStore, setLeaveGuard } from "/assets/app-nav.js";
17
- import { chatPath, settingsPath, SETTINGS_SECTIONS } from "/assets/app-route.js";
17
+ import { discardHeldPrefs, heldPrefsCount, holdPrefs, onHeldPrefs, savePrefsHeld } from "/assets/app-prefs.js";
18
+ import { chatPath, LEGACY_SETTINGS, sectionPath, settingsPath, SETTINGS_GROUPS, SETTINGS_SECTIONS } from "/assets/app-route.js";
19
+ import { goToSetting, goToTarget, searchSettings, settingLabel, takePendingSetting } from "./settings-registry.js";
18
20
  import { Dialog } from "/assets/ui-dialog.js";
19
21
  import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
20
22
  import { Icon } from "/assets/ui-icons.js";
@@ -25,14 +27,14 @@ import "./settings-strings.js";
25
27
  import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
26
28
  import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
27
29
  import {
28
- ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
30
+ ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, ownChoiceImpact, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
29
31
  changedFields, channelId, channelsOf, chLabel, connectionState, dashboardDefault, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
30
- hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, shortName, STATUS_ORDER, toYaml,
32
+ hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, sameValue, shortName, STATUS_ORDER, toYaml,
31
33
  toolSetDefault, visibilityDefault, worstImpact,
32
34
  } from "./settings-model.js";
33
35
  import {
34
- AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, Drawer, Feedback, Impact, Label, ModelSelect, NewBotDialog, Override,
35
- Select, ask, impactText,
36
+ AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, DefaultMark, Drawer, Feedback, Impact, InEffect, Inherited, Label, ModelSelect,
37
+ NewBotDialog, Override, Select, ask, impactText, inAgentDefaults, valueText,
36
38
  } from "./settings-dialogs.js";
37
39
  import { SetupWizard } from "./settings-wizard.js";
38
40
  import { CreateInstanceDialog } from "./panel-fleet.js";
@@ -44,6 +46,8 @@ import { startTour } from "./chat-tour.js";
44
46
  import "./chat-strings.js";
45
47
  import { AppearanceSection } from "./settings-appearance.js";
46
48
  import { GuestsCard } from "./settings-guests.js";
49
+ import { OfficeSharesCard } from "./office-share-dialog.js";
50
+ import { syncStage, useDraft } from "./settings-draft.js";
47
51
  import { WebAuthorityCard } from "./settings-web-authority.js";
48
52
  import { SignInList } from "./backend-signin.js";
49
53
 
@@ -52,7 +56,7 @@ const tn = (k, ...v) => t(`settings.${k}`, ...v);
52
56
  const APPLY_ERRORS = new Set(["unsupported_sensitive_effect", "confirmation_diff_too_large", "session_required", "admin_required", "settings_changed",
53
57
  "request_no_longer_current", "too_many_pending_changes", "fleet_unavailable", "invalid_settings_effect", "settings_confirmation_unavailable", "failed"]);
54
58
  export const applyErrorText = (code) => (APPLY_ERRORS.has(code) ? tn(`applyError_${code}`) : tn("applyErrorOther", String(code)));
55
- const ICONS = { device: "user", appearance: "palette", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
59
+ const ICONS = { preferences: "user", look: "palette", agents: "bot", "agent-defaults": "sliders", chat: "chat", "web-dashboard": "globe", access: "key", advanced: "code" };
56
60
  const RELEASES = "https://github.com/songsid/AgEnD/releases";
57
61
 
58
62
  /** GET under the panel's lease → { ok, status, body }. */
@@ -107,7 +111,9 @@ async function loadLive(lease) {
107
111
  }
108
112
 
109
113
  export function SettingsPanel({ route, navKey }) {
110
- const section = SETTINGS_SECTIONS.includes(route.section) ? route.section : "agents";
114
+ // #2347: an old section name (a route made before the regrouping) is its new section.
115
+ const moved = Object.prototype.hasOwnProperty.call(LEGACY_SETTINGS, route.section) ? LEGACY_SETTINGS[route.section].split("#")[0] : null;
116
+ const section = SETTINGS_SECTIONS.includes(route.section) ? route.section : moved || "agents";
111
117
  const mount = useLease("settings"); // the panel's life on screen: its data, its guard
112
118
  const [data, setData] = useState(null);
113
119
  const [staged, setStaged] = useState(() => new Map());
@@ -115,6 +121,33 @@ export function SettingsPanel({ route, navKey }) {
115
121
  stagedRef.current = staged;
116
122
  const [dialog, setDialog] = useState(null); // { kind, ...props, key: navKey }
117
123
  const [search, setSearch] = useState("");
124
+ // #2347: each section's draft while it is edited (settings-draft.js), and the epoch that remounts the sections when
125
+ // the drafts go: a Save that went through whole, or Discard. A Save that failed keeps them (what did not land is staged
126
+ // again, and the form still shows it).
127
+ const drafts = useRef(new Map());
128
+ const [draftEpoch, setDraftEpoch] = useState(0);
129
+ // A staged change's onResult hears how its Save settled: "applied", "waiting" (for a fleet admin's OK) or "failed".
130
+ const results = useRef(new Map());
131
+ // The drafts as they were when a Save began. #2367 review: the form stays editable while it runs, so a Save that went
132
+ // through drops only the drafts it saved — one edited since (in any section) stays, and stays staged.
133
+ const submitted = useRef(null);
134
+ // The staged changes as they were when that Save began (what comes back after a failure is them, whole: a section's
135
+ // draft and onResult included), and how a Save went for a change whose section was shown again since (#2367 review:
136
+ // a failed Save reloads, so the section that staged it is a new one by the time it could be told).
137
+ const submittedStaged = useRef(new Map());
138
+ const outcomes = useRef(new Map());
139
+ // #2367 r3: how the last Save went for each change it carried ("applied" / "failed"), until its configuration is read
140
+ // back: a change staged against both ends of a Save (`afterSave: { applied, failed }`) becomes the one that came true.
141
+ const settledAs = useRef(new Map());
142
+ const resolveAfterSave = (key, change) => {
143
+ if (!change || !change.afterSave) return change;
144
+ const v = change.afterSave[settledAs.current.get(key) === "applied" ? "applied" : "failed"];
145
+ return v ? { ...v, stageKey: key } : null;
146
+ };
147
+ const dropDrafts = (saved = null) => {
148
+ drafts.current = saved ? new Map([...drafts.current].filter(([k, v]) => !saved.has(k) || !sameValue(v, saved.get(k)))) : new Map();
149
+ setDraftEpoch((n) => n + 1);
150
+ };
118
151
  const { settingsOp: op, pendingChanges } = useStore(appStore);
119
152
  const gen = useRef(0);
120
153
 
@@ -133,6 +166,12 @@ export function SettingsPanel({ route, navKey }) {
133
166
  setData((d) => (d && !d.error ? { ...d, ...live } : d));
134
167
  };
135
168
 
169
+ // #2347: the "You" group — a preference chosen here (Preferences, Look) is shown at once and saved by the bar's
170
+ // Save with everything else: held in app-prefs while Settings is open, and staged here as one change.
171
+ const syncHeld = (n) => (n ? stage(YOU_PREFS, { label: tn("youPrefs"), impact: ownChoiceImpact(data && data.schema), count: n,
172
+ local: { commit: savePrefsHeld, revert: discardHeldPrefs } }, true) : unstage(YOU_PREFS));
173
+ useEffect(() => { const release = holdPrefs(); syncHeld(heldPrefsCount()); const off = onHeldPrefs(syncHeld); return () => { off(); release(); }; }, []);
174
+
136
175
  // Mount: the data; requests still waiting for an admin from before a reload; the leave guard; the browser's prompt.
137
176
  useEffect(() => {
138
177
  reload();
@@ -148,6 +187,7 @@ export function SettingsPanel({ route, navKey }) {
148
187
  asking = false;
149
188
  if (!yes || !mount.current()) return;
150
189
  dropSecrets(stagedRef.current);
190
+ revertLocal(stagedRef.current);
151
191
  stagedRef.current = new Map();
152
192
  navigate(path, { force: true });
153
193
  });
@@ -155,6 +195,14 @@ export function SettingsPanel({ route, navKey }) {
155
195
  return false;
156
196
  }));
157
197
  mount.on(window, "beforeunload", (e) => { if (stagedRef.current.size) { e.preventDefault(); e.returnValue = ""; } });
198
+ // #2380 review: a reload or a closed tab confirmed past that question keeps nothing unsaved — this person's and this
199
+ // device's own choices are put back before the page goes (a device switch is in this browser's storage at once).
200
+ mount.on(window, "pagehide", () => {
201
+ if (![...stagedRef.current.values()].some((c) => c.local)) return;
202
+ revertLocal(stagedRef.current);
203
+ stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
204
+ setStaged((s) => new Map([...s].filter(([, c]) => !c.local)));
205
+ });
158
206
  }, [mount]);
159
207
 
160
208
  // Changes a failed Apply did not land come back to be staged again (secrets excepted: they are entered again).
@@ -162,20 +210,64 @@ export function SettingsPanel({ route, navKey }) {
162
210
  if (!op || !op.leftover || !op.leftover.length) return;
163
211
  const back = takeLeftover();
164
212
  if (!back.length) return;
165
- setStaged((s) => { const m = new Map(s); for (const c of back) m.set(c.stageKey || newKey("left"), c); return m; });
213
+ setStaged((s) => {
214
+ const m = new Map(s);
215
+ // #2367 review: a change staged for the same thing since that Save began is the newer word (its draft and
216
+ // request carry everything still unsaved): it stays, and the failed one is not put back over it.
217
+ for (const c of back) {
218
+ const k = c.stageKey || newKey("left");
219
+ if (!m.has(k)) m.set(k, { ...(submittedStaged.current.get(k) || {}), ...c, stageKey: k });
220
+ }
221
+ return m;
222
+ });
166
223
  }, [op && op.leftover]);
167
224
 
168
225
  // An Apply that finished, or a confirmed request that applied, changed the configuration: read it again.
169
226
  const lastPhase = useRef(op ? op.phase : null);
170
227
  useEffect(() => {
171
228
  const phase = op ? op.phase : null;
172
- if (lastPhase.current !== phase && (phase === "done" || phase === "failed")) reload();
229
+ if (lastPhase.current !== phase && (phase === "done" || phase === "failed")) {
230
+ settle(op);
231
+ settledAs.current = new Map(op.steps.filter((s) => s.stageKey).map((s) => [s.stageKey, s.status === "done" ? "applied" : "failed"]));
232
+ setStaged((s) => {
233
+ if (![...s.values()].some((c) => c.afterSave)) return s;
234
+ const m = new Map();
235
+ for (const [k, c] of s) { const r = resolveAfterSave(k, c); if (r) m.set(k, r); }
236
+ return m;
237
+ });
238
+ if (phase !== "done") settledAs.current = new Map();
239
+ // After a Save that went through, its drafts go once the configuration it wrote is read back — not before: a
240
+ // section shown on the old configuration would take an edit back to the old value for no edit at all.
241
+ const saved = phase === "done" ? submitted.current : null;
242
+ if (!saved) submitted.current = null;
243
+ reload().finally(() => { if (saved && submitted.current === saved) { dropDrafts(saved); submitted.current = null; settledAs.current = new Map(); } });
244
+ }
173
245
  lastPhase.current = phase;
174
246
  }, [op && op.phase]);
247
+ // A saved change that waits for a fleet admin's OK is told so while it waits (its section says it is not saved yet).
248
+ const waitingNow = op ? op.steps.filter((s) => s.status === "waiting" && s.stageKey).map((s) => s.stageKey).join(",") : "";
249
+ useEffect(() => {
250
+ for (const k of waitingNow ? waitingNow.split(",") : []) {
251
+ const fn = results.current.get(k);
252
+ try { if (fn) fn({ status: "waiting", error: null }); } catch { /* a section's own handler: never the Save's */ }
253
+ }
254
+ }, [waitingNow]);
175
255
  const applied = (pendingChanges || []).filter((p) => p.state === "applied").map((p) => p.id).join(",");
176
256
  useEffect(() => { if (applied) reload(); }, [applied]);
177
257
 
178
258
  const stage = (key, change, quiet = false) => {
259
+ // A change staged against both ends of a Save that has settled since is the end that came true (none: nothing left).
260
+ if (change && change.afterSave && settledAs.current.size) {
261
+ change = resolveAfterSave(key, change);
262
+ if (!change) { unstage(key); return false; }
263
+ }
264
+ // #2347: the editor's edit and a form's change to the same settings never wait together (the controls of each are
265
+ // off while the other is unsaved; this is the last word for a way in that is not).
266
+ const now = stagedRef.current;
267
+ if (isYamlKey(key) ? formsStaged(now) : writesModel(change) && yamlStaged(now)) {
268
+ toast(tn(isYamlKey(key) ? "devFormsUnsaved" : "yamlUnsaved"), false);
269
+ return false;
270
+ }
179
271
  setStaged((s) => {
180
272
  const m = new Map(s);
181
273
  const prev = m.get(key);
@@ -187,12 +279,32 @@ export function SettingsPanel({ route, navKey }) {
187
279
  return m;
188
280
  });
189
281
  if (!quiet) toast(tn("queued", change.label));
282
+ // A section shown again after its Save failed (or still waits) hears how it went as it stages its change again.
283
+ if (typeof change.onResult === "function" && outcomes.current.has(key)) {
284
+ const outcome = outcomes.current.get(key);
285
+ outcomes.current.delete(key);
286
+ try { change.onResult(outcome); } catch { /* a section's own handler: never the Save's */ }
287
+ }
288
+ return true;
190
289
  };
191
290
  const unstage = (key) => setStaged((s) => { if (!s.has(key)) return s; const m = new Map(s); m.delete(key); return m; });
192
- const discard = () => { dropSecrets(staged); setStaged(new Map()); reload(); };
291
+ const discard = () => { dropSecrets(staged); revertLocal(staged); setStaged(new Map()); outcomes.current = new Map(); dropDrafts(); reload(); };
292
+ /** Tell each saved change's section how its Save settled (#2347: the onResult in the change it staged). */
293
+ const settle = (o) => {
294
+ for (const step of (o && o.steps) || []) {
295
+ const fn = step.stageKey && results.current.get(step.stageKey);
296
+ if (!fn) continue;
297
+ const status = step.status === "done" ? "applied" : step.status === "waiting" ? "waiting" : "failed";
298
+ if (status !== "applied") outcomes.current.set(step.stageKey, { status, error: step.error || null });
299
+ try { fn({ status, error: step.error || null }); } catch { /* a section's own handler: never the Save's */ }
300
+ }
301
+ results.current = new Map();
302
+ };
303
+
193
304
  const applying = useRef(false);
194
305
  const apply = async () => {
195
306
  if (applying.current || operationActive() || !staged.size) return;
307
+ if ([...stagedRef.current.values()].some((c) => c.invalid)) return; // #2347: Save waits for every field to be valid
196
308
  // Every confirmation before the first write: a cancelled one must not leave earlier changes applied and later
197
309
  // ones pending (a surprising partial apply).
198
310
  applying.current = true;
@@ -207,19 +319,42 @@ export function SettingsPanel({ route, navKey }) {
207
319
  }
208
320
  } finally { applying.current = false; }
209
321
  if (!mount.current() || operationActive()) return;
322
+ // #2347: this person's and this device's own choices are saved here, first (no apply job: nothing in fleet.yaml).
323
+ const locals = [...stagedRef.current.values()].filter((c) => c.local);
324
+ if (locals.length) {
325
+ let failed = false;
326
+ for (const c of locals) { try { await c.local.commit(); } catch { failed = true; } }
327
+ if (!mount.current()) return;
328
+ setStaged((s) => { const m = new Map(s); for (const c of locals) m.delete(c.stageKey); return m; });
329
+ stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
330
+ // #2380 review: a refusal is said whatever else this Save carries (the host's values are back on the page).
331
+ if (failed) toast(tn("youSaveFailed"), false);
332
+ if (!stagedRef.current.size) { if (!failed) toast(tn("youSaved")); setDraftEpoch((n) => n + 1); return; }
333
+ }
210
334
  const list = [...stagedRef.current.values()].map(({ label, impact, request, connectionSecret, stageKey, key }) => ({ label, impact, request, connectionSecret, stageKey, key }));
211
- if (startOperation(list)) setStaged(new Map());
335
+ const handlers = new Map([...stagedRef.current.values()].filter((c) => typeof c.onResult === "function").map((c) => [c.stageKey, c.onResult]));
336
+ if (startOperation(list)) {
337
+ results.current = handlers; submitted.current = new Map(drafts.current); submittedStaged.current = new Map(stagedRef.current);
338
+ outcomes.current = new Map(); setStaged(new Map());
339
+ }
212
340
  };
213
341
 
214
- const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current()) : null;
342
+ // #2347: sections read `staged` (each change as staged, its extra fields kept: Guests carries its whole draft) and
343
+ // `drafts` to come back to their edits; `draftEpoch` moves on Discard and after a Save to start them from what is saved.
344
+ const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current(),
345
+ { drafts: drafts.current, draftEpoch, staged, savingDrafts: () => submitted.current,
346
+ savingStaged: () => (submitted.current || operationActive() ? submittedStaged.current : null) }) : null;
215
347
  // #1519 P7: "Connect a chat app" on the first-run card elsewhere came here for the wizard: it opens once the data is in.
216
- useEffect(() => { if (ctx && takeWizardRequest()) setDialog({ kind: "wizard", key: navKey }); }, [!!ctx, navKey]);
348
+ // #2347: it writes a connection and an agent at once — not while a fleet.yaml edit waits to be saved.
349
+ const openWizard = () => { if (yamlStaged(stagedRef.current)) toast(tn("yamlUnsaved"), false); else setDialog({ kind: "wizard", key: navKey }); };
350
+ useEffect(() => { if (ctx && takeWizardRequest()) openWizard(); }, [!!ctx, navKey]);
217
351
  // #1523 N2: "Edit in Settings" on an instance's Details: that agent's dialog, once the data is in — only an agent this
218
352
  // configuration has (own key), with Settings' own staged Apply and confirmations.
219
353
  useEffect(() => {
220
354
  if (!ctx) return;
221
355
  const want = takeAgentRequest();
222
356
  if (!want) return;
357
+ if (yamlStaged(stagedRef.current)) { toast(tn("yamlUnsaved"), false); return; } // #2347: the editor's edit waits
223
358
  const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
224
359
  if (want.kind === "classic") {
225
360
  // A ClassicBot room: its own dialog (ClassicDialog), found by its instance among this config's rooms.
@@ -232,37 +367,112 @@ export function SettingsPanel({ route, navKey }) {
232
367
  if (own(insts, want.name)) setDialog({ kind: "agent", name: want.name, inst: insts[want.name], key: navKey });
233
368
  }, [!!ctx, navKey]);
234
369
  // #1604: This device needs no configuration: shown at once, whatever the configuration read does.
235
- const sectionBody = section === "device" ? html`<${DeviceSection} />`
236
- // #1629 r4 §3: Appearance is the person's own (no configuration read): shown at once, like Preferences.
237
- : section === "appearance" ? html`<${AppearanceSection} />`
370
+ // #2347: drawn again from what is in force after a Discard or a Save (draftEpoch), like every section.
371
+ const toDialog = (d) => setDialog({ ...d, key: navKey });
372
+ const you = { key: `you:${draftEpoch}`, stage, unstage, staged, schema: data && data.schema };
373
+ const formKey = data && !data.error ? `${data.loadedAt}:${draftEpoch}` : "";
374
+ // #2347 regrouping: each card where the person looks for it (the IA in #2347 §3); a form split across sections (the
375
+ // defaults, Web) is one form with one draft, shown a part at a time.
376
+ const sectionBody = section === "preferences" ? html`<${DeviceSection} key=${you.key} /><${AppearanceSection} ...${you} part="device" />`
377
+ : section === "look" ? html`<${AppearanceSection} ...${you} part="look" />`
238
378
  : !data ? html`<${Skeleton} lines=${6} />`
239
379
  : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
240
- : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
241
- : section === "bots" ? html`<${Bots} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
242
- : section === "classic" ? html`<${Classic} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
243
- : section === "general" ? html`<${General} key=${data.loadedAt} ctx=${ctx} />`
244
- : section === "web" ? html`<${WebSection} key=${data.loadedAt} ctx=${ctx} staged=${staged} />`
245
- : html`<${Developer} ctx=${ctx} />`;
380
+ : section === "agents" ? html`<${YamlLock} staged=${staged}><${Agents} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`
381
+ : section === "agent-defaults" ? html`<${YamlLock} staged=${staged}><${General} key=${formKey} ctx=${ctx} part="fleet" /></${YamlLock}>`
382
+ : section === "chat" ? html`<${YamlLock} staged=${staged}><${Bots} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>
383
+ <${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="chat" />
384
+ <div id="classicbot" class="s-anchor"><h2 class="s-ap-group">ClassicBot</h2><${Classic} ctx=${ctx} search=${search} openDialog=${toDialog} />
385
+ <${General} key=${formKey} ctx=${ctx} part="classic" /></div>`
386
+ : section === "web-dashboard" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="dashboard" /><${AppearanceSection} ...${you} part="dashboard" />`
387
+ : section === "access" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="access" />`
388
+ : html`<${Developer} key=${`dev:${draftEpoch}`} ctx=${ctx} /><${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="readonly" />`;
389
+ // #2347 §6: the setting a search result opened — brought into view once its section is drawn (its data may still be
390
+ // on its way), focused, and flashed for 2 s.
391
+ const wanted = useRef(null);
392
+ // Each navigation's own: one made since (without a setting to find) cancels it (#2383 review).
393
+ useEffect(() => { wanted.current = takePendingSetting(); }, [navKey]);
394
+ useEffect(() => {
395
+ if (!wanted.current) return undefined;
396
+ let tries = 0, timer = null, live = true;
397
+ const find = () => {
398
+ if (!live || !wanted.current) return;
399
+ const el = document.querySelector(wanted.current);
400
+ if (!el) {
401
+ // A section that draws from the configuration waits for it however long its read takes (#2383 review: a slow
402
+ // link); once it is drawn, the setting gets ~2 s to appear (a card loading its own data), then is given up.
403
+ if (section !== "preferences" && section !== "look" && !data) return; // run again when the data is in
404
+ if (++tries < 40) timer = setTimeout(find, 50); else wanted.current = null;
405
+ return;
406
+ }
407
+ wanted.current = null;
408
+ // #2347 §10: a setting in a closed drawer (Advanced) is shown — every drawer around it opens first.
409
+ for (let d = el.closest && el.closest("details"); d; d = d.parentElement && d.parentElement.closest("details")) d.open = true;
410
+ if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "nearest" });
411
+ const field = el.matches && el.matches("input, select, textarea, button") ? el : el.querySelector && el.querySelector("input, select, textarea, button");
412
+ if (field && typeof field.focus === "function") field.focus({ preventScroll: true });
413
+ const row = (el.closest && el.closest(".field, label.check, .card")) || el;
414
+ row.classList.add("s-flash");
415
+ setTimeout(() => row.classList.remove("s-flash"), 2000);
416
+ };
417
+ find();
418
+ return () => { live = false; clearTimeout(timer); };
419
+ }, [section, navKey, !!data, draftEpoch]);
420
+ useEffect(() => {
421
+ const onKey = (e) => {
422
+ if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
423
+ const tgt = e.target;
424
+ if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
425
+ const box = document.getElementById("settingsSearch");
426
+ if (box) { e.preventDefault(); box.focus(); }
427
+ };
428
+ document.addEventListener("keydown", onKey);
429
+ return () => document.removeEventListener("keydown", onKey);
430
+ }, []);
431
+ // #2347: on a phone the sections are one row that scrolls sideways: the one shown is brought into it.
432
+ useEffect(() => {
433
+ const item = typeof document !== "undefined" ? document.querySelector(".s-nav .seg-item.active") : null;
434
+ const row = item && item.closest ? item.closest(".s-nav-groups") : null;
435
+ // That row only (scrollIntoView would also scroll the page sideways, and the nav column on a wide screen).
436
+ if (row && row.scrollWidth > row.clientWidth) {
437
+ const ir = item.getBoundingClientRect(), rr = row.getBoundingClientRect();
438
+ if (ir.left < rr.left || ir.right > rr.right) row.scrollLeft += ir.left - rr.left - (rr.width - ir.width) / 2;
439
+ }
440
+ }, [section]);
441
+ // #2347: a place in a section (`/settings/chat#classicbot`, an old /settings/classic link): there, once it is drawn.
442
+ useEffect(() => {
443
+ const id = (typeof location !== "undefined" && location.hash ? location.hash.slice(1) : "");
444
+ if (!id) return;
445
+ let el = null;
446
+ try { el = document.getElementById(decodeURIComponent(id)); } catch { el = null; }
447
+ if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "start" });
448
+ }, [section, navKey, !!data]);
246
449
  // A dialog belongs to the navigation that opened it (its draft names one object, never another).
247
450
  const open = dialog && dialog.key === navKey ? dialog : null;
248
451
  const close = () => setDialog(null);
249
- const searchable = section === "agents" || section === "bots" || section === "classic";
250
- return html`<div data-effect-panel="settings" class="panel p-settings">
452
+ const searchable = section === "agents" || section === "chat";
453
+ // #2347 §7: on a phone, Settings itself (/settings) is the list of sections with search on top; a section opens on its
454
+ // own with "‹ Settings" back to it. A wider screen always shows the list beside the section (the CSS decides).
455
+ // Which one is shown is the root's data-view: its class stays exactly the effects producer's ("panel p-settings", #1629).
456
+ const atList = typeof location !== "undefined" && location.pathname === "/settings";
457
+ return html`<div data-effect-panel="settings" class="panel p-settings" data-view=${atList ? "index" : "section"}>
251
458
  <${PanelHeader} title=${tn("title")} sub=${data && data.version ? html`<a class="pill ver" href=${data.version.includes("-") ? `${RELEASES}/latest` : `${RELEASES}/tag/v${data.version}`} target="_blank" rel="noopener">v${data.version}</a>` : null}>
252
- <button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx} onClick=${() => setDialog({ kind: "wizard", key: navKey })}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
459
+ <button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx || yamlStaged(staged)} title=${ctx && yamlStaged(staged) ? tn("yamlUnsaved") : undefined} onClick=${openWizard}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
253
460
  <button type="button" class="icon-btn" aria-label=${tn("helpButton")} title=${tn("helpButton")} onClick=${() => setDialog({ kind: "help", key: navKey })}><${Icon} name="info" /></button>
254
461
  </${PanelHeader}>
255
- <nav data-trust="panel-navigation" class="seg" aria-label=${tn("title")}>${SETTINGS_SECTIONS.map((k) => html`<a key=${k} href=${settingsPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
256
- aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</nav>
462
+ <nav data-trust="panel-navigation" class="seg s-nav" aria-label=${tn("title")}><${SettingsSearch} /><div class="s-nav-groups">${SETTINGS_GROUPS.map((g) => html`<div key=${g.id} class="s-nav-group" role="group" aria-labelledby=${`s-grp-${g.id}`}>
463
+ <span class="s-nav-head" id=${`s-grp-${g.id}`}>${tn(`grp_${g.id}`)}</span>
464
+ ${g.sections.map((k) => html`<a key=${k} href=${sectionPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
465
+ aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</div>`)}</div></nav>
257
466
  <div data-trust="panel-content" class="panel-body"><div class="col">
467
+ <a class="s-back" href=${settingsPath()}><${Icon} name="back" size=${16} /><span>${tn("title")}</span><span class="s-back-here">${tn(`sec_${section}`)}</span></a>
258
468
  ${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
259
469
  <input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
260
470
  ${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
261
471
  <${PendingChanges} inline=${true} />
262
- ${ctx && (section === "agents" || section === "bots") ? html`<${FirstRunCard} onConnect=${() => setDialog({ kind: "wizard", key: navKey })} />` : null}
472
+ ${ctx && (section === "agents" || section === "chat") ? html`<${FirstRunCard} onConnect=${openWizard} />` : null}
263
473
  ${sectionBody}
264
474
  </div></div>
265
- ${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
475
+ ${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} section=${section} />` : null}
266
476
  ${open && ctx ? renderDialog(open, ctx, close) : null}
267
477
  ${open && open.kind === "help" ? html`<${HelpDialog} onClose=${close} />` : null}
268
478
  </div>`;
@@ -274,6 +484,87 @@ export function SettingsPanel({ route, navKey }) {
274
484
  * read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
275
485
  * chat it points at), and the layout choices named there (their controls are where they always were).
276
486
  */
487
+ /** The words of `text` that `query` names, marked (for a result's name). */
488
+ function marked(text, query) {
489
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
490
+ if (!terms.length) return text;
491
+ const lower = text.toLowerCase(), out = [];
492
+ let i = 0;
493
+ while (i < text.length) {
494
+ let best = -1, len = 0;
495
+ for (const term of terms) { const at = lower.indexOf(term, i); if (at !== -1 && (best === -1 || at < best)) { best = at; len = term.length; } }
496
+ if (best === -1) { out.push(text.slice(i)); break; }
497
+ if (best > i) out.push(text.slice(i, best));
498
+ out.push(html`<mark>${text.slice(best, best + len)}</mark>`);
499
+ i = best + len;
500
+ }
501
+ return out;
502
+ }
503
+
504
+ /**
505
+ * #2347 §6: search every setting — by its name, its config key, or the words people use (both languages: "token",
506
+ * "語言", "port") — results grouped by section with where they are; Enter or a click goes there. "/" focuses it.
507
+ */
508
+ function SettingsSearch() {
509
+ const [q, setQ] = useState("");
510
+ const [active, setActive] = useState(0);
511
+ const hits = useMemo(() => searchSettings(q).slice(0, 40), [q]);
512
+ // Grouped by section, the group of the best match first (its order is the ranking's): Enter takes the best.
513
+ const groups = useMemo(() => [...new Set(hits.map((e) => e.section))].map((s) => ({ section: s, items: hits.filter((e) => e.section === s) })), [hits]);
514
+ const flat = groups.flatMap((g) => g.items);
515
+ const groupOf = (s) => SETTINGS_GROUPS.find((g) => g.sections.includes(s));
516
+ const go = (entry) => { if (!entry) return; setQ(""); setActive(0); goToSetting(entry); };
517
+ const onKey = (e) => {
518
+ if (e.isComposing) return;
519
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (flat.length ? (i + 1) % flat.length : 0)); }
520
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0)); }
521
+ else if (e.key === "Enter") { e.preventDefault(); go(flat[Math.min(active, flat.length - 1)]); }
522
+ else if (e.key === "Escape") { if (q) { e.preventDefault(); e.stopPropagation(); setQ(""); } else e.target.blur(); }
523
+ };
524
+ const open = q.trim() !== "";
525
+ const at = Math.min(active, Math.max(flat.length - 1, 0));
526
+ let n = -1;
527
+ return html`<div class="s-find" role="search">
528
+ <label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchSettings")}</span>
529
+ <input id="settingsSearch" type="search" value=${q} placeholder=${tn("searchSettings")} autocomplete="off" spellcheck=${false}
530
+ role="combobox" aria-expanded=${open ? "true" : "false"} aria-controls="s-find-results" aria-autocomplete="list"
531
+ aria-activedescendant=${open && flat.length ? `s-find-${at}` : undefined}
532
+ onInput=${(e) => { setQ(e.target.value); setActive(0); }} onKeyDown=${onKey} /></label>
533
+ ${open ? html`<div id="s-find-results" class="s-find-results" role="listbox" aria-label=${tn("searchSettings")}>
534
+ ${flat.length ? groups.map((g) => html`<div key=${g.section} role="group" class="s-find-group" aria-label=${tn(`sec_${g.section}`)}>
535
+ <div class="s-find-head" aria-hidden="true">${tn(`grp_${groupOf(g.section).id}`)} › ${tn(`sec_${g.section}`)}</div>
536
+ ${g.items.map((entry) => { n++; const i = n; return html`<div key=${entry.id} id=${`s-find-${i}`} role="option" aria-selected=${i === at ? "true" : "false"}
537
+ class=${`s-find-item${i === at ? " on" : ""}`} onMouseDown=${(e) => e.preventDefault()} onMouseMove=${i === at ? undefined : () => setActive(i)} onClick=${() => go(entry)}>
538
+ <span class="s-find-label">${marked(settingLabel(entry), q)}</span>${entry.key ? html`<code class="s-find-key">${entry.key}</code>` : null}</div>`; })}
539
+ </div>`) : html`<p class="s-find-none" role="status">${tn("searchNone")}</p>`}
540
+ <p class="s-find-foot" aria-hidden="true">${tn("searchFoot")}</p>
541
+ </div>` : null}
542
+ </div>`;
543
+ }
544
+
545
+ /** #2347: the staged change that stands for every held "You" preference (app-prefs.js holdPrefs). */
546
+ const YOU_PREFS = "you:prefs";
547
+ /** #2347: the fleet.yaml editor's staged writes (Advanced → Developer), one per write in order: `yaml:0`, `yaml:1`, … */
548
+ const YAML_KEY = "yaml:";
549
+ const isYamlKey = (k) => typeof k === "string" && k.startsWith(YAML_KEY);
550
+ // What the editor writes (fleet.yaml's defaults, channels and agents), and the setup wizard's commit of both.
551
+ const MODEL_WRITE = /^\/api\/settings\/(fleet\/(defaults|channels|instances)(\/|$)|quickstart\/)/;
552
+ const writesModel = (c) => !!(c && c.request && MODEL_WRITE.test(String(c.request.url || "")));
553
+ /** #2347: the editor and the forms that write the same settings refuse to mix: one waits for the other's Save or Discard. */
554
+ const yamlStaged = (staged) => [...staged.keys()].some(isYamlKey);
555
+ const formsStaged = (staged) => [...staged].some(([k, c]) => !isYamlKey(k) && writesModel(c));
556
+ /** A form that writes what a staged fleet.yaml edit writes: shown, not edited, until that edit is saved or discarded. */
557
+ function YamlLock({ staged, children }) {
558
+ const locked = yamlStaged(staged);
559
+ return html`<fieldset class="s-yaml-lock" disabled=${locked}>
560
+ ${locked ? html`<p class="note s-yaml-lock-note" role="status">${tn("yamlUnsaved")} <a href=${settingsPath("advanced")}>${tn("yamlOpen")}</a></p>` : null}
561
+ ${children}</fieldset>`;
562
+ }
563
+ /** Put back this person's and this device's own choices among `changes` (a Discard, or leaving Settings with them). */
564
+ function revertLocal(changes) {
565
+ for (const c of [...changes.values()].reverse()) if (c.local) { try { c.local.revert(); } catch { /* the rest still go back */ } }
566
+ }
567
+
277
568
  export function DeviceSection() {
278
569
  const P = () => globalThis.AgendPreview;
279
570
  const [on, setOn] = useState(P() ? P().optedIn() : false);
@@ -312,7 +603,7 @@ export function DeviceSection() {
312
603
  <section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
313
604
  <p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
314
605
  <${DevicePrefs} labels=${true} />
315
- ${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
606
+ ${P() ? html`<label class="check"><input id="pref-previews" type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
316
607
  <span>${t("chat.previews")}</span></label>` : null}
317
608
  </section>
318
609
  <section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
@@ -382,12 +673,12 @@ function renderDialog(d, ctx, close) {
382
673
  }
383
674
 
384
675
  /** What the sections and dialogs work with: the data, and the ways to change it (staging, or a direct write). */
385
- function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
676
+ function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive, extra = {}) {
386
677
  const { fleet, classic, schema } = data;
387
678
  const chs = channelsOf(fleet);
388
679
  const edit = (fn) => setData((d) => { const next = structuredClone(d); fn(next); return next; });
389
680
  return {
390
- ...data, reload, reloadLive, stage, unstage, shortName,
681
+ ...data, ...extra, reload, reloadLive, stage, unstage, shortName,
391
682
  channelType: (id) => { const ch = chs.find((c, i) => channelId(c, i) === id); return ch ? ch.type : undefined; },
392
683
  classicRuntime: (instanceName) => {
393
684
  const live = data.classicLive[instanceName] || {};
@@ -475,7 +766,7 @@ function Agents({ ctx, search, openDialog }) {
475
766
  try { await work(); } finally { setBusy((b) => { const n = new Map(b); n.delete(name); return n; }); }
476
767
  };
477
768
  const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
478
- <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
769
+ <button type="button" id="newAgentBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
479
770
  // #2233: after the agents, each login on this machine with Sign in (backend-signin.js).
480
771
  const signIns = html`<${SignInList} />`;
481
772
  if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />${signIns}`;
@@ -542,7 +833,7 @@ function Bots({ ctx, search, openDialog }) {
542
833
  const needle = search.trim().toLowerCase();
543
834
  const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
544
835
  const head = html`<div class="list-head"><span>${all.length === 1 ? tn("countBot") : tn("countBots", all.length)}</span>
545
- <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
836
+ <button type="button" id="newBotBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
546
837
  return html`${head}
547
838
  ${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
548
839
  const users = (ch.access && ch.access.allowed_users) || [];
@@ -569,8 +860,7 @@ function Bots({ ctx, search, openDialog }) {
569
860
  ${!overPublicLink && ctx.fleetUp && meta.status !== "connected" ? html`<${ConnectionRetry} id=${channelId(ch, i)} ctx=${ctx} />` : null}
570
861
  <button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
571
862
  </div>`;
572
- })}</div>`}
573
- ${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`;
863
+ })}</div>`}`;
574
864
  }
575
865
 
576
866
  /**
@@ -620,7 +910,7 @@ function ProviderKeys({ ctx }) {
620
910
  } catch { say(spec.id, { error: tn("failed") }); }
621
911
  finally { inFlight.current.delete(spec.id); if (lease.current()) setBusy(new Set(inFlight.current)); }
622
912
  };
623
- return html`<section class="card"><h3>${tn("providerKeys")}</h3>
913
+ return html`<section class="card s-provider-keys"><h3>${tn("providerKeys")}</h3>
624
914
  ${!ctx.providerSecrets.length ? html`<p class="note">${tn("noProviderVerifiers")}</p>` : ctx.providerSecrets.map((spec) => html`<div key=${spec.id} class="s-key">
625
915
  <div class="s-key-row"><strong>${spec.display_name}</strong><span class="tag">${spec.token_present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
626
916
  ${spec.verifier === "available" ? html`<input type="password" autocomplete="new-password" aria-label=${tn("providerKeyInput", spec.display_name)}
@@ -663,141 +953,200 @@ function Classic({ ctx, search, openDialog }) {
663
953
  })}</div>`;
664
954
  }
665
955
 
666
- /**
667
- * General: the fleet's defaults, ClassicBot's, web chat. "Review changes" stages up to three changes (defaults,
668
- * ClassicBot defaults, web) — each only with what differs from what this form showed, so changing one hot default
669
- * never also stages untouched settings as a fleet restart.
670
- */
671
- function General({ ctx }) {
672
- const { schema } = ctx;
673
- const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
956
+ // Sign-in notices and daily tips post in the topic or not at all: the DM choice was withdrawn (hmsg-d1809f2a,
957
+ // hmsg-3b8ac2af), and an old "dm" reads as on.
958
+ const noticeMode = (v) => v !== "false" && v !== false;
959
+ const noticeChoices = () => [{ value: "true", label: tn("noticeTopic") }, { value: "false", label: tn("noticeOff") }];
960
+ const noticeLabel = (v) => tn(v ? "noticeTopic" : "noticeOff");
961
+
962
+ /** General's form, from the saved defaults (`d`) and ClassicBot's (`c`). */
963
+ function generalForm(d, c, fleet = {}, schema = {}) {
674
964
  const hang = d.hang_detector || {}, startup = d.startup || {};
675
- const primary = channelsOf(ctx.fleet)[0];
676
- const original = useMemo(() => ({ c: structuredClone(c) }), []);
677
- const [f, setF] = useState(() => ({
965
+ return {
678
966
  backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
679
967
  agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : "", tool_progress: d.tool_progress ?? "off",
680
968
  visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
681
- dash: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, dashboardDefault(d, k)])),
969
+ // #2495 review: one field per dashboard feature, so a draft keeps only the features edited (a whole-object field
970
+ // would bring back a feature another admin changed meanwhile).
971
+ ...Object.fromEntries(DASHBOARD_FEATURES.map((k) => [`dash_${k}`, dashboardDefault(d, k)])),
682
972
  hangOn: hang.enabled !== false, hang: String(hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES),
683
- concurrency: String(startup.concurrency ?? 10), stagger: String(startup.stagger_delay_ms ?? 500),
973
+ // #2347 §10 review: unset startup concurrency is automatic (deriveSpawnConcurrency: 2–10 by free memory and cores),
974
+ // not 10 — an empty field, and a save that leaves it unset (the defaults merge replaces `startup` whole).
975
+ concurrency: startup.concurrency == null ? "" : String(startup.concurrency), stagger: String(startup.stagger_delay_ms ?? 500),
976
+ tips: String(noticeMode(d.tips)),
977
+ replyTriggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default),
978
+ classicReplyTriggers: Boolean(c.reply_triggers ?? d.reply_triggers ?? schema.reply_triggers_default), classicReplyTriggersInherit: !hasOwn(c, "reply_triggers"),
684
979
  classicBackend: c.backend || d.backend || "claude-code",
685
980
  classicToolProgress: c.tool_progress ?? d.tool_progress ?? "off", classicToolProgressInherit: !hasOwn(c, "tool_progress"),
686
981
  classicReply: c.reply_completion_guard ?? d.reply_completion_guard ?? true, classicReplyInherit: !hasOwn(c, "reply_completion_guard"),
687
982
  admins: [...(c.admin_users || [])], guilds: [...(c.allowed_guilds || [])],
688
- }));
689
- const [msg, setMsg] = useState(null);
983
+ roots: [...(fleet.project_roots || [])],
984
+ };
985
+ }
986
+
987
+ /**
988
+ * General: the fleet's defaults and ClassicBot's. #2347: an edit is unsaved at once — staged (up to two changes:
989
+ * defaults, ClassicBot defaults), each only with what differs from what is saved, so changing one hot default never also
990
+ * stages untouched settings as a fleet restart; the sticky bar saves them. A changed field carries the straight bar.
991
+ */
992
+ function General({ ctx, part = "all" }) {
993
+ const { schema } = ctx;
994
+ const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
995
+ const hang = d.hang_detector || {}, startup = d.startup || {};
996
+ const primary = channelsOf(ctx.fleet)[0];
997
+ const original = useMemo(() => ({ c: structuredClone(c) }), []);
998
+ const base = useMemo(() => generalForm(d, c, ctx.fleet, schema), []);
999
+ const [f, setF] = useDraft(ctx, "general", () => base);
690
1000
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
1001
+ /** The class of a field's row: `field`, and `s-dirty` while one of its keys differs from what is saved. */
1002
+ const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], base[k])) ? " s-dirty" : ""}`;
691
1003
  const num = (v) => Number(v);
692
1004
  const errors = {
693
1005
  auto: nonNegative(f.auto), hang: positive(f.hang),
694
- concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
1006
+ concurrency: f.concurrency.trim() !== "" && (!Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20) ? "concurrencyRange" : "",
695
1007
  stagger: !Number.isFinite(num(f.stagger)) || f.stagger.trim() === "" || num(f.stagger) < 0 || num(f.stagger) > 30000 ? "staggerRange" : "",
696
1008
  };
697
- const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
1009
+ const warnings = { concurrency: f.concurrency.trim() !== "" && num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
698
1010
  const ready = !Object.values(errors).some(Boolean);
699
- const review = () => {
700
- if (!ready) return;
1011
+ const firstError = Object.values(errors).find(Boolean);
1012
+ // #2347: every edit, staged as it is made (the first render included: a draft kept from before is staged again).
1013
+ useEffect(() => {
1014
+ if (!ready) {
1015
+ // Where it is (#2382 review): the bar in another section names it and goes there.
1016
+ const at = { auto: "#g-auto", hang: "#g-hang", concurrency: "#g-conc", stagger: "#g-stag" }[Object.keys(errors).find((k) => errors[k])];
1017
+ syncStage(ctx, "defaults:invalid", { label: tn("updateDefaults"), invalid: tn(firstError), section: "agent-defaults", target: at });
1018
+ return;
1019
+ }
1020
+ ctx.unstage("defaults:invalid");
701
1021
  const defaultsPatch = changedFields({
702
1022
  backend: f.backend, model: f.model || undefined, locale: f.locale || null, auto_pause_after: num(f.auto),
703
1023
  agent_mode: f.agent_mode, tool_set: f.tool_set || null, tool_progress: f.tool_progress, cross_instance_visibility: f.visibility,
704
- reply_completion_guard: f.reply, log_level: f.log_level,
705
- // The dashboard features every agent may use unless its own setting says otherwise (all three, as shown).
706
- dashboard: f.dash,
1024
+ tips: noticeMode(f.tips), reply_triggers: f.replyTriggers, reply_completion_guard: f.reply, log_level: f.log_level,
1025
+ // The dashboard features every agent may use unless its own setting says otherwise. All of them go, because the write
1026
+ // to the fleet's defaults merges top-level keys only (`dashboard` is replaced whole): the untouched ones as saved.
1027
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, f[`dash_${k}`]])),
1028
+ office_access: f.dash_office,
707
1029
  hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
708
- startup: { concurrency: num(f.concurrency), stagger_delay_ms: num(f.stagger) },
1030
+ startup: { ...(f.concurrency.trim() === "" ? {} : { concurrency: num(f.concurrency) }), stagger_delay_ms: num(f.stagger) },
709
1031
  }, {
710
1032
  backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
711
1033
  agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
712
- cross_instance_visibility: visibilityDefault(d), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
713
- dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, dashboardDefault(d, k)])),
1034
+ tips: d.tips ?? true, cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1035
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, dashboardDefault(d, k)])),
1036
+ office_access: dashboardDefault(d, "office"),
714
1037
  hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
715
- startup: { concurrency: startup.concurrency ?? 10, stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
1038
+ startup: { ...(startup.concurrency == null ? {} : { concurrency: startup.concurrency }), stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
716
1039
  });
717
1040
  const oc = original.c;
718
1041
  const classicPatch = changedFields({
1042
+ reply_triggers: f.classicReplyTriggersInherit ? null : f.classicReplyTriggers,
719
1043
  backend: f.classicBackend,
720
1044
  tool_progress: f.classicToolProgressInherit ? null : f.classicToolProgress,
721
1045
  reply_completion_guard: f.classicReplyInherit ? null : f.classicReply,
722
1046
  admin_users: f.admins, allowed_guilds: f.guilds,
723
1047
  }, {
1048
+ reply_triggers: hasOwn(oc, "reply_triggers") ? oc.reply_triggers : null,
724
1049
  backend: oc.backend || d.backend || "claude-code",
725
1050
  tool_progress: hasOwn(oc, "tool_progress") ? oc.tool_progress : null,
726
1051
  reply_completion_guard: hasOwn(oc, "reply_completion_guard") ? oc.reply_completion_guard : null,
727
1052
  admin_users: oc.admin_users || [], allowed_guilds: oc.allowed_guilds || [],
728
1053
  });
729
- if (Object.keys(defaultsPatch).length) {
730
- ctx.stage("defaults:fleet", { label: tn("updateDefaults"), impact: batchImpact(schema, Object.keys(defaultsPatch), (k) => defaultsImpact(schema, k)),
731
- request: { method: "PUT", url: "/api/settings/fleet/defaults", body: defaultsPatch } });
732
- }
733
- if (Object.keys(classicPatch).length) {
734
- const hotOnly = Object.keys(classicPatch).every((k) => impactOf(schema, `classic_defaults.${k}`) === "now");
735
- ctx.stage("defaults:classic", { label: tn("updateClassicDefaults"), impact: hotOnly ? "now" : "fleet",
736
- request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } });
737
- }
738
- setMsg(tn("readyApply"));
1054
+ // #2347 §10: where new agents may work (fleet.project_roots, from Fleet → Config): its own write, the one route
1055
+ // that sets it (POST /ui/config; a change to it waits for a fleet admin's OK, as it did there).
1056
+ const roots = f.roots.map((r) => String(r).trim()).filter(Boolean); // ChipList keeps "2024" as a number; paths are text
1057
+ syncStage(ctx, "fleet:project_roots", sameValue(roots, base.roots) ? null : { label: tn("projectRootsTitle"), count: 1,
1058
+ impact: impactOf(schema, "fleet.project_roots"), request: { method: "POST", url: "/ui/config", body: { project_roots: roots } } });
1059
+ const dKeys = Object.keys(defaultsPatch), cKeys = Object.keys(classicPatch);
1060
+ syncStage(ctx, "defaults:fleet", dKeys.length ? { label: tn("updateDefaults"), count: dKeys.length,
1061
+ impact: batchImpact(schema, dKeys, (k) => defaultsImpact(schema, k)),
1062
+ request: { method: "PUT", url: "/api/settings/fleet/defaults", body: defaultsPatch } } : null);
1063
+ syncStage(ctx, "defaults:classic", cKeys.length ? { label: tn("updateClassicDefaults"), count: cKeys.length,
1064
+ impact: cKeys.every((k) => impactOf(schema, `classic_defaults.${k}`) === "now") ? "now" : "fleet",
1065
+ request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } } : null);
1066
+ }, [f, ready]);
1067
+ // #2347 §10: a default nobody set in fleet.yaml shows the built-in value it holds, marked "default" until it is edited;
1068
+ // ClassicBot's inherited ones say what they inherit from Agent defaults.
1069
+ const unset = {
1070
+ locale: !d.locale, backend: !d.backend, model: !d.model, auto: !hasOwn(d, "auto_pause_after"), tool_progress: !hasOwn(d, "tool_progress"),
1071
+ tips: !hasOwn(d, "tips"), replyTriggers: !hasOwn(d, "reply_triggers"), visibility: !VISIBILITY_MODES.includes(d.cross_instance_visibility), reply: !hasOwn(d, "reply_completion_guard"), tool_set: !d.tool_set,
1072
+ log_level: !d.log_level, agent_mode: !d.agent_mode, hangOn: !hasOwn(hang, "enabled"), hang: !hasOwn(hang, "timeout_minutes"),
1073
+ concurrency: !hasOwn(startup, "concurrency"), stagger: !hasOwn(startup, "stagger_delay_ms"),
739
1074
  };
740
- const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
1075
+ const dm = (k) => (unset[k] && sameValue(f[k], base[k]) ? html`<${DefaultMark} />` : null);
1076
+ const fromDefaults = (k, value, target) => ({ text: valueText(value), builtin: !hasOwn(d, k), from: inAgentDefaults(target) });
1077
+ const L = (text, field, id, k) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} mark=${k ? dm(k) : null} />`;
741
1078
  const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
742
1079
  const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
743
- return html`<div class="form s-general">
744
- <section class="card"><h3>${tn("defaultsTitle")}</h3>
1080
+ // #2347: Agent defaults show the fleet's part; Chat & connections ClassicBot's. One form, one draft either way.
1081
+ const fleetPart = part !== "classic", classicPart = part !== "fleet";
1082
+ return html`<div class=${`form s-general s-part-${part}`}>
1083
+ ${fleetPart ? html`<section class="card"><h3>${tn("defaultsTitle")}</h3>
745
1084
  <div class="grid2">
746
- <div class="field">${L(tn("language"), "defaults.locale", "g-locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
1085
+ <div class=${fc("field", "locale")}>${L(tn("language"), "defaults.locale", "g-locale", "locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
747
1086
  options=${localeOptions(f.locale).map((o) => (o.auto ? { value: "", label: tn("localeAuto") } : o))} /></div>
748
- <div class="field">${L(tn("defaultBackend"), "defaults.backend", "g-be")}<${BackendSelect} id="g-be" value=${f.backend} onChange=${set("backend")} /></div>
749
- <div class="field">${L(tn("defaultModel"), "defaults.model", "g-model")}<${ModelSelect} id="g-model" value=${f.model} onChange=${set("model")}
1087
+ <div class=${fc("field", "backend")}>${L(tn("defaultBackend"), "defaults.backend", "g-be", "backend")}<${BackendSelect} id="g-be" value=${f.backend} onChange=${set("backend")} /></div>
1088
+ <div class=${fc("field", "model")}>${L(tn("defaultModel"), "defaults.model", "g-model", "model")}<${ModelSelect} id="g-model" value=${f.model} onChange=${set("model")}
750
1089
  backend=${f.backend || "claude-code"} inherit=${tn("modelCliDefault")} /></div>
751
- <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "defaults.auto_pause_after", "g-auto")}<input id="g-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} value=${f.auto} onInput=${(e) => set("auto")(e.target.value)} /><${Feedback} error=${errors.auto} /></div>
752
- <div class="field">${L(tn("toolProgress"), "defaults.tool_progress", "g-tp")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
753
- <div class="field">${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
1090
+ <div class=${fc("field", "auto")}>${L(`${tn("autoPause")} (${tn("minutes")})`, "defaults.auto_pause_after", "g-auto", "auto")}<input id="g-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} value=${f.auto} onInput=${(e) => set("auto")(e.target.value)} /><${Feedback} error=${errors.auto} /></div>
1091
+ <div class=${fc("field", "tool_progress")}>${L(tn("toolProgress"), "defaults.tool_progress", "g-tp", "tool_progress")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
1092
+ <div class=${fc("field", "visibility")}>${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis", "visibility")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
754
1093
  <p class="note">${tn("crossInstanceVisibilityHint")}</p></div>
755
- <div class="field"><label class="check"><input type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span><${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
1094
+ <div class=${fc("field", "tips")}>${L(tn("dailyTips"), "defaults.tips", "g-tips", "tips")}<${Select} id="g-tips" value=${f.tips} onChange=${set("tips")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.tips))} /><p class="note">${tn("dailyTipsHint")}</p></div>
1095
+ <div class=${fc("field", "replyTriggers")}><label class="check"><input id="g-reply-triggers" type="checkbox" checked=${f.replyTriggers} onChange=${(e) => set("replyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span>${dm("replyTriggers")}<${Impact} schema=${schema} field="defaults.reply_triggers" /></label><p class="note">${tn("replyTriggersHint")}</p></div>
1096
+ <div class=${fc("field", "reply")}><label class="check"><input id="g-reply" type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span>${dm("reply")}<${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
756
1097
  <p class="note">${fleetReply}</p></div>
757
1098
  </div>
758
- <fieldset class="field dash-perms"><legend>${L(tn("dashboardAccess"), "defaults.dashboard")}</legend>
1099
+ <fieldset id="g-dash" class=${fc("field dash-perms", ...DASHBOARD_FEATURES.map((k) => `dash_${k}`))}><legend>${L(tn("dashboardAccess"), "defaults.dashboard")}</legend>
759
1100
  <p class="note">${tn("dashboardDefaultsHint")}</p>
760
- ${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f.dash[k]}
761
- onChange=${(e) => setF((x) => ({ ...x, dash: { ...x.dash, [k]: e.target.checked } }))} /><span>${tn(`dashboard_${k}`)}</span></label>`)}
1101
+ ${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f[`dash_${k}`]}
1102
+ onChange=${(e) => set(`dash_${k}`)(e.target.checked)} /><span>${tn(`dashboard_${k}`)}</span></label>`)}
762
1103
  </fieldset>
763
- </section>
764
- <section class="card"><h3>ClassicBot</h3>
1104
+ </section>` : null}
1105
+ ${classicPart ? html`<section class="card"><h3>${tn("classicDefaultsTitle")}</h3>
765
1106
  <div class="grid2">
766
- <div class="field">${L(tn("classicBackend"), "classic_defaults.backend", "g-cbe")}<${BackendSelect} id="g-cbe" value=${f.classicBackend} onChange=${set("classicBackend")} /></div>
767
- <div class="field">${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
768
- <${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")}>
1107
+ <div class=${fc("field", "classicBackend")}>${L(tn("classicBackend"), "classic_defaults.backend", "g-cbe")}<${BackendSelect} id="g-cbe" value=${f.classicBackend} onChange=${set("classicBackend")} />
1108
+ ${!c.backend && f.classicBackend === base.classicBackend ? html`<${Inherited} text=${valueText(f.classicBackend, "backend")} builtin=${!d.backend} from=${inAgentDefaults("#g-be")} />` : null}</div>
1109
+ <div class=${fc("field", "classicToolProgress", "classicToolProgressInherit")}>${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
1110
+ <${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")} inherited=${fromDefaults("tool_progress", d.tool_progress ?? "off", "#g-tp")}>
769
1111
  <${Select} id="g-ctp" disabled=${f.classicToolProgressInherit} value=${f.classicToolProgressInherit ? (d.tool_progress ?? "off") : f.classicToolProgress} onChange=${set("classicToolProgress")} options=${TOOL_PROGRESS} /></${Override}></div>
770
- <div class="field">${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
771
- <${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")}>
772
- <label class="check"><input type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
1112
+ <div class=${fc("field", "classicReplyTriggers", "classicReplyTriggersInherit")}>${L(tn("replyTriggers"), "classic_defaults.reply_triggers")}
1113
+ <${Override} inherit=${f.classicReplyTriggersInherit} onInherit=${set("classicReplyTriggersInherit")} inherited=${fromDefaults("reply_triggers", Boolean(d.reply_triggers ?? schema.reply_triggers_default), "#g-reply-triggers")}>
1114
+ <label class="check"><input id="g-creply-triggers" type="checkbox" disabled=${f.classicReplyTriggersInherit} checked=${f.classicReplyTriggersInherit ? Boolean(d.reply_triggers ?? schema.reply_triggers_default) : f.classicReplyTriggers} onChange=${(e) => set("classicReplyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span></label></${Override}><p class="note">${tn("replyTriggersHint")}</p></div>
1115
+ <div class=${fc("field", "classicReply", "classicReplyInherit")}>${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
1116
+ <${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")} inherited=${fromDefaults("reply_completion_guard", d.reply_completion_guard ?? true, "#g-reply")}>
1117
+ <label class="check"><input id="g-creply" type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
773
1118
  <p class="note">${classicReply}</p></div>
774
1119
  </div>
775
1120
  <h4>${tn("whoUsesClassic")}</h4>
776
1121
  <div class="grid2">
777
- <div class="field">${L(tn("classicAdmins"), "classic.admin_users")}<${ChipList} items=${f.admins} onChange=${set("admins")} placeholder=${tn("userIdPlaceholder")} label=${tn("classicAdmins")} /></div>
778
- <div class="field">${L(tn("allowedGuilds"), "classic.allowed_guilds")}<${ChipList} items=${f.guilds} onChange=${set("guilds")} placeholder=${tn("guildIdPlaceholder")} label=${tn("allowedGuilds")} /></div>
1122
+ <div id="g-admins" class=${fc("field", "admins")}>${L(tn("classicAdmins"), "classic.admin_users")}<${ChipList} items=${f.admins} onChange=${set("admins")} placeholder=${tn("userIdPlaceholder")} label=${tn("classicAdmins")} /></div>
1123
+ <div id="g-guilds" class=${fc("field", "guilds")}>${L(tn("allowedGuilds"), "classic.allowed_guilds")}<${ChipList} items=${f.guilds} onChange=${set("guilds")} placeholder=${tn("guildIdPlaceholder")} label=${tn("allowedGuilds")} /></div>
779
1124
  </div>
780
- </section>
781
- <${Drawer} title=${tn("advancedSection")}>
1125
+ </section>` : null}
1126
+ ${fleetPart ? html`<section class="card" id="project-roots"><h3>${tn("projectRootsTitle")}</h3>
1127
+ <p class="note">${tn("projectRootsHint")}</p>
1128
+ <div id="g-roots" class=${fc("field", "roots")}>${L(tn("projectRootsTitle"), "fleet.project_roots")}<${ChipList} items=${f.roots} onChange=${set("roots")} placeholder="/path/to/projects" label=${tn("projectRootsTitle")} />
1129
+ ${f.roots.length ? null : html`<${InEffect} text=${tn("projectRootsAnywhere")} />`}</div>
1130
+ </section>` : null}
1131
+ ${fleetPart ? html`<${Drawer} title=${tn("advancedSection")}>
782
1132
  <section class="card"><h3>${tn("runtimeResources")}</h3><div class="grid2">
783
- <div class="field">${L(tn("toolSet"), "defaults.tool_set", "g-ts")}<${Select} id="g-ts" value=${f.tool_set} onChange=${set("tool_set")}
1133
+ <div class=${fc("field", "tool_set")}>${L(tn("toolSet"), "defaults.tool_set", "g-ts", "tool_set")}<${Select} id="g-ts" value=${f.tool_set} onChange=${set("tool_set")}
784
1134
  options=${[{ value: "", label: tn("toolSetUnset", toolSetDefault({}, {}), toolSetDefault({}, { general_topic: true })) }, ...TOOL_SETS]} /></div>
785
- <div class="field">${L(tn("logLevel"), "defaults.log_level", "g-ll")}<${Select} id="g-ll" value=${f.log_level} onChange=${set("log_level")} options=${LOG_LEVELS} /></div>
786
- <div class="field">${L(tn("agentMode"), "defaults.agent_mode", "g-am")}<${Select} id="g-am" value=${f.agent_mode} onChange=${set("agent_mode")} options=${AGENT_MODES} /></div>
1135
+ <div class=${fc("field", "log_level")}>${L(tn("logLevel"), "defaults.log_level", "g-ll", "log_level")}<${Select} id="g-ll" value=${f.log_level} onChange=${set("log_level")} options=${LOG_LEVELS} /></div>
1136
+ <div class=${fc("field", "agent_mode")}>${L(tn("agentMode"), "defaults.agent_mode", "g-am", "agent_mode")}<${Select} id="g-am" value=${f.agent_mode} onChange=${set("agent_mode")} options=${AGENT_MODES} /></div>
787
1137
  </div></section>
788
1138
  <section class="card"><h3>${tn("detection")}</h3><div class="grid2">
789
- <label class="check"><input type="checkbox" checked=${f.hangOn} onChange=${(e) => set("hangOn")(e.target.checked)} /><span>${tn("hangEnabled")}</span><${Impact} schema=${schema} field="defaults.hang_detector" /></label>
790
- <div class="field">${L(`${tn("hangTimeout")} (${tn("minutes")})`, "defaults.hang_detector", "g-hang")}<input id="g-hang" type="number" min="0.1" step="0.5" class=${errors.hang ? "invalid" : ""} value=${f.hang} onInput=${(e) => set("hang")(e.target.value)} /><${Feedback} error=${errors.hang} /></div>
1139
+ <label class=${fc("check", "hangOn")}><input id="g-hang-on" type="checkbox" checked=${f.hangOn} onChange=${(e) => set("hangOn")(e.target.checked)} /><span>${tn("hangEnabled")}</span>${dm("hangOn")}<${Impact} schema=${schema} field="defaults.hang_detector" /></label>
1140
+ <div class=${fc("field", "hang")}>${L(`${tn("hangTimeout")} (${tn("minutes")})`, "defaults.hang_detector", "g-hang", "hang")}<input id="g-hang" type="number" min="0.1" step="0.5" class=${errors.hang ? "invalid" : ""} value=${f.hang} onInput=${(e) => set("hang")(e.target.value)} /><${Feedback} error=${errors.hang} /></div>
791
1141
  </div></section>
792
1142
  <section class="card"><h3>${tn("startup")}</h3><div class="grid2">
793
- <div class="field">${L(tn("concurrency"), "fleet.spawn_concurrency", "g-conc")}<input id="g-conc" type="number" min="1" max="20" step="1" class=${errors.concurrency ? "invalid" : ""} value=${f.concurrency} onInput=${(e) => set("concurrency")(e.target.value)} /><${Feedback} error=${errors.concurrency} warning=${warnings.concurrency} /></div>
794
- <div class="field">${L(`${tn("staggerDelay")} (ms)`, "fleet.spawn_stagger_ms", "g-stag")}<input id="g-stag" type="number" min="0" max="30000" step="100" class=${errors.stagger ? "invalid" : ""} value=${f.stagger} onInput=${(e) => set("stagger")(e.target.value)} /><${Feedback} error=${errors.stagger} warning=${warnings.stagger} /></div>
1143
+ <div class=${fc("field", "concurrency")}>${L(tn("concurrency"), "fleet.spawn_concurrency", "g-conc")}<input id="g-conc" type="number" min="1" max="20" step="1" class=${errors.concurrency ? "invalid" : ""} value=${f.concurrency} onInput=${(e) => set("concurrency")(e.target.value)} /><${Feedback} error=${errors.concurrency} warning=${warnings.concurrency} />
1144
+ ${f.concurrency.trim() === "" ? html`<${InEffect} text=${tn("concurrencyAuto")} />` : null}</div>
1145
+ <div class=${fc("field", "stagger")}>${L(`${tn("staggerDelay")} (ms)`, "fleet.spawn_stagger_ms", "g-stag", "stagger")}<input id="g-stag" type="number" min="0" max="30000" step="100" class=${errors.stagger ? "invalid" : ""} value=${f.stagger} onInput=${(e) => set("stagger")(e.target.value)} /><${Feedback} error=${errors.stagger} warning=${warnings.stagger} /></div>
795
1146
  </div></section>
796
1147
  <section class="card"><h3>${tn("accessTitle")}</h3>
797
1148
  <p class="note">${primary ? tn("accessMovedHint", primary.id || chLabel(0)) : tn("noBots")}</p></section>
798
- </${Drawer}>
799
- <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
800
- ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
1149
+ </${Drawer}>` : null}
801
1150
  </div>`;
802
1151
  }
803
1152
 
@@ -810,16 +1159,40 @@ function General({ ctx }) {
810
1159
  * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
811
1160
  */
812
1161
  export const WEB_READ_ONLY = [
813
- "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
1162
+ "web.allowed_hosts", "web.view_access", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
814
1163
  "web.usage_panel", "web.search", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
815
1164
  ];
816
- function WebSection({ ctx, staged }) {
1165
+ /**
1166
+ * #2347 §10: what each of them is when fleet.yaml leaves it out, as the host applies it (null: nothing — none is used).
1167
+ * web.preview_port: the web port + 1 (previewSettings); the uploads: UPLOAD_LIMITS (#1937); the terminal: 127.0.0.1.
1168
+ */
1169
+ export function webReadOnlyDefault(path, fleet) {
1170
+ // previewSettings: a web port of 0 (tests, harnesses) gets an ephemeral one; past 65535 there is no preview listener.
1171
+ const hp = fleet.health_port ?? 19280;
1172
+ return {
1173
+ "web.allowed_hosts": [], "web.view_access": "open", "web.notify_login": true, "web.preview": true, "web.preview_port": hp === 0 ? 0 : hp + 1 <= 65535 ? hp + 1 : null,
1174
+ // #2518: web.search unset is no age limit (the 300,000-row cap still applies); its line says so itself.
1175
+ "web.preview_origin": null, "web.preview_on_public_link": true, "web.usage_panel": true, "web.search": null, "web.provider_secrets": false,
1176
+ "web.upload_max_file_mb": uploadMbInEffect(fleet.web).file, "web.upload_max_total_mb": 100, "web_terminal.bind": "127.0.0.1", "web_terminal.tunnel": null,
1177
+ }[path];
1178
+ }
1179
+ /**
1180
+ * #2347 §10 review: the upload limits in force, in MB, as uploadLimits (#1937) resolves them: a value that is not a whole
1181
+ * 1–500 is the default (50 / 100), and one file is never allowed more than a whole message.
1182
+ */
1183
+ export function uploadMbInEffect(web) {
1184
+ const mb = (v, dflt) => (Number.isInteger(v) && v >= 1 && v <= 500 ? v : dflt);
1185
+ const total = mb(web && web.upload_max_total_mb, 100);
1186
+ return { file: Math.min(mb(web && web.upload_max_file_mb, 50), total), total };
1187
+ }
1188
+ function WebSection({ ctx, staged, part = "all" }) {
817
1189
  const { schema } = ctx;
818
1190
  const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
819
1191
  // Over the public link the fields read only; the Guests and Web authority cards are not offered (the server never
820
1192
  // serves their reads there).
821
1193
  const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
822
1194
  const original = useMemo(() => ({
1195
+ notify: noticeMode(web.notify_login),
823
1196
  echo: web.echo_to_channel ?? true,
824
1197
  log: web.log_tab === true, // #1218: off by default
825
1198
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
@@ -828,7 +1201,12 @@ function WebSection({ ctx, staged }) {
828
1201
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
829
1202
  }), []);
830
1203
  const text = (v) => (v === null || v === undefined ? "" : String(v));
831
- const [f, setF] = useState(() => {
1204
+ /** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
1205
+ const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
1206
+ ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
1207
+ abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
1208
+ termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
1209
+ const [f, setF] = useDraft(ctx, "web", () => savedForm, () => {
832
1210
  // Settings sections share one draft. Reopening Web must show it as well as preserve it in the Apply bar, so a
833
1211
  // subsequent field edit keeps earlier edits. The original disk values remain the diff/confirmation baseline.
834
1212
  const request = staged.get("web")?.request;
@@ -836,6 +1214,7 @@ function WebSection({ ctx, staged }) {
836
1214
  const pub = pending.public_link || {}, session = pending.session?.local || {}, terminal = pending.web_terminal || {};
837
1215
  const pick = (obj, key, fallback) => Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
838
1216
  return {
1217
+ notify: String(pick(pending, "notify_login", original.notify)),
839
1218
  echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
840
1219
  pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
841
1220
  hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
@@ -843,12 +1222,12 @@ function WebSection({ ctx, staged }) {
843
1222
  termOn: pick(terminal, "enabled", original.terminal.enabled) !== false, termTtl: text(pick(terminal, "ttl_minutes", original.terminal.ttl_minutes)),
844
1223
  };
845
1224
  });
846
- const [msg, setMsg] = useState(null);
847
1225
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
1226
+ const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], savedForm[k])) ? " s-dirty" : ""}`;
848
1227
  // Empty = the default (null in the write: the key is removed from fleet.yaml).
849
1228
  const value = (v) => (v.trim() === "" ? null : Number(v));
850
1229
  const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
851
- // The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
1230
+ // The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY; the terminal's 10 min); an idle time left blank follows a shorter session, as
852
1231
  // the store and the validator apply it — so only an idle time that is typed can exceed the session length.
853
1232
  const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
854
1233
  // Named mode: a hostname is typed (the server judges the merged result the same way).
@@ -866,8 +1245,14 @@ function WebSection({ ctx, staged }) {
866
1245
  };
867
1246
  const ready = !overPublicLink && !Object.values(errors).some(Boolean);
868
1247
  const review = (quiet = false) => {
869
- // Invalid intermediate text must not leave an older valid draft available to Apply.
870
- if (!ready) { if (staged.has("web")) ctx.unstage("web"); setMsg(null); return; }
1248
+ // #2347: invalid text keeps the draft unsaved and Save waiting ("Fix 1 field to save"); no older valid draft is sent.
1249
+ if (overPublicLink) return;
1250
+ if (!ready) {
1251
+ const at = { hostname: "#publicWebHostname", originPort: "#publicWebOriginPort", ttl: "#publicWebTtl", abs: "#webSessionAbsolute", idle: "#webSessionIdle",
1252
+ termTtl: "#webTerminalTtl" }[Object.keys(errors).find((k) => errors[k])];
1253
+ syncStage(ctx, "web", { label: tn("webSettingsLabel"), invalid: tn(Object.values(errors).find(Boolean)), section: "access", target: at });
1254
+ return;
1255
+ }
871
1256
  // Keep an existing secret reference, including a custom one. Only a newly supplied hostname without any
872
1257
  // reference adopts the standard env key; mounting or editing an unrelated field never rewrites credentials.
873
1258
  const tokenEnv = f.hostname === original.pub.hostname ? original.pub.tunnel_token_env
@@ -878,6 +1263,7 @@ function WebSection({ ctx, staged }) {
878
1263
  const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
879
1264
  ttl_minutes: value(f.termTtl) }, original.terminal);
880
1265
  const body = {
1266
+ ...(noticeMode(f.notify) !== original.notify ? { notify_login: noticeMode(f.notify) } : {}),
881
1267
  ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
882
1268
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
883
1269
  ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
@@ -886,6 +1272,7 @@ function WebSection({ ctx, staged }) {
886
1272
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
887
1273
  };
888
1274
  const fields = [
1275
+ ...(body.notify_login !== undefined ? ["web.notify_login"] : []),
889
1276
  ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
890
1277
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
891
1278
  ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
@@ -894,10 +1281,9 @@ function WebSection({ ctx, staged }) {
894
1281
  ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
895
1282
  ];
896
1283
  if (fields.length) {
897
- ctx.stage("web", { label: tn("sec_web"), impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
1284
+ ctx.stage("web", { label: tn("webSettingsLabel"), count: fields.length, impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
898
1285
  request: { method: "PUT", url: "/api/settings/fleet/web", body } }, quiet);
899
1286
  } else if (staged.has("web")) ctx.unstage("web");
900
- setMsg(fields.length ? tn("readyApply") : null);
901
1287
  };
902
1288
  // This form already has validation and a single shared Web draft. Valid edits join that draft immediately;
903
1289
  // Apply, its confirmations and the leave guard still own all HTTP writes. Quiet staging avoids a toast per key.
@@ -905,6 +1291,9 @@ function WebSection({ ctx, staged }) {
905
1291
  // parent's already-staged Web draft. Only an actual field edit replaces or removes that draft.
906
1292
  const formMounted = useRef(false);
907
1293
  useEffect(() => { if (!formMounted.current) { formMounted.current = true; return; } review(true); }, [f]);
1294
+ const roText = (v) => (v === null ? tn("valueNone") : typeof v === "object" ? JSON.stringify(v) : String(v));
1295
+ // #2347 §10: a key fleet.yaml leaves out shows the value the host uses, marked "default".
1296
+ const uploads = uploadMbInEffect(web);
908
1297
  const shown = (path) => {
909
1298
  const [top, key] = path.split(".");
910
1299
  if (top === "web" && key === "search") {
@@ -914,72 +1303,106 @@ function WebSection({ ctx, staged }) {
914
1303
  return String(days);
915
1304
  }
916
1305
  const v = (top === "web" ? web : term)[key];
917
- return v === undefined ? tn("notSet") : typeof v === "object" ? JSON.stringify(v) : String(v);
1306
+ // An upload limit that is not what applies (out of range, or a file above the whole message's) says what does.
1307
+ const upload = top === "web" ? { upload_max_file_mb: uploads.file, upload_max_total_mb: uploads.total }[key] : undefined;
1308
+ const applies = upload === undefined ? v : upload;
1309
+ const capped = top === "web" && key === "upload_max_file_mb" && uploads.file < 50; // left out, only the whole message's limit lowers it
1310
+ if (v === undefined) return html`${roText(webReadOnlyDefault(path, ctx.fleet))} <${DefaultMark} />${capped ? html` <span class="tag">${tn("cappedByTotal")}</span>` : null}`;
1311
+ return applies === v ? roText(v) : html`${roText(v)} <span class="tag">${tn("inEffectShort", roText(applies))}</span>`;
918
1312
  };
919
- return html`<div class="form s-web">
920
- <p class="sub">${tn("webLead")}</p>
921
- ${overPublicLink ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
922
- <fieldset class="s-web-fields" disabled=${overPublicLink}>
923
- <section class="card"><h3>${tn("webSessionTitle")}</h3>
1313
+ // #2347: one form, one draft, shown where each part belongs — Access & security (sessions, the browser terminal, the
1314
+ // public link and its fixed address, guests, web authority), Chat & connections (web echo), Web dashboard (the Log tab,
1315
+ // usage over the public link), Advanced (what only fleet.yaml sets).
1316
+ const has = (p) => part === "all" || part === p;
1317
+ const lock = (body) => html`<fieldset class="s-web-fields" disabled=${overPublicLink}>${body}</fieldset>`;
1318
+ return html`<div class=${`form s-web s-part-${part}`}>
1319
+ ${has("access") ? html`<p class="sub">${tn("webLead")}</p>` : null}
1320
+ ${overPublicLink && part !== "readonly" ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
1321
+ ${has("access") ? lock(html`<section class="card"><h3>${tn("webSessionTitle")}</h3>
924
1322
  <p class="note">${tn("webSessionHint")}</p>
1323
+ <div class=${fc("field", "notify")}><label for="webNotifyLogin">${tn("webNotifyLogin")}<${Impact} schema=${schema} field="web.notify_login" /></label><${Select} id="webNotifyLogin" value=${f.notify} onChange=${set("notify")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.notify))} />${web.notify_login === undefined ? html`<${DefaultMark} />` : null}<p class="note">${tn("webNotifyLoginHint")}</p></div>
925
1324
  <div class="grid2">
926
- <div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
927
- class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} /></div>
928
- <div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
929
- class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} /></div>
1325
+ <div class=${fc("field", "abs")}><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any"
1326
+ class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} />
1327
+ ${value(f.abs) === null ? html`<${InEffect} text=${tn("hoursValue", 48)} />` : null}</div>
1328
+ <div class=${fc("field", "idle")}><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any"
1329
+ class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} />
1330
+ ${value(f.idle) === null && !errors.abs ? html`<${InEffect} text=${tn("hoursValue", idle)} why=${idle < 48 ? tn("cappedBySession") : null} />` : null}</div>
930
1331
  </div>
931
1332
  </section>
932
1333
  <section class="card"><h3>${tn("webTerminalTitle")}</h3>
933
1334
  <p class="note">${tn("webTerminalHint")}</p>
934
- <label class="check"><input id="webTerminalEnabled" type="checkbox" checked=${f.termOn} onChange=${(e) => set("termOn")(e.target.checked)} /><span>${tn("webTerminalEnabled")}</span><${Impact} schema=${schema} field="web_terminal.enabled" /></label>
1335
+ <label class=${fc("check", "termOn")}><input id="webTerminalEnabled" type="checkbox" checked=${f.termOn} onChange=${(e) => set("termOn")(e.target.checked)} /><span>${tn("webTerminalEnabled")}</span><${Impact} schema=${schema} field="web_terminal.enabled" /></label>
935
1336
  <div class="grid2">
936
- <div class="field"><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1" placeholder=${tn("defaultHours", 10)}
937
- class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} /></div>
1337
+ <div class=${fc("field", "termTtl")}><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1"
1338
+ class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} />
1339
+ ${value(f.termTtl) === null ? html`<${InEffect} text=${tn("minutesValue", 10)} />` : null}</div>
938
1340
  </div>
939
1341
  </section>
940
- <section class="card"><h3>${tn("webChat")}</h3>
941
- <label class="check"><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
942
- <p class="note">${tn("webEchoHint")}</p>
943
- <label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
944
- <p class="note">${tn("webLogTabHint")}</p>
945
- <label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
946
- <p class="note">${tn("webUsagePublicHint")}</p>
947
- <label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
1342
+ <section class="card"><h3>${tn("publicLinkTitle")}</h3>
1343
+ <label class=${fc("check", "pub")}><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
948
1344
  <p class="note">${tn("publicLinkHint")}</p>
949
1345
  <div class="grid2">
950
- <div class="field"><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
951
- <div class="field"><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
1346
+ <div class=${fc("field", "hostname")}><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
1347
+ <div class=${fc("field", "originPort")}><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
952
1348
  </div>
953
1349
  <p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
954
1350
  <div class="grid2">
955
- <div class="field"><label for="publicWebTtl">${tn(named ? "publicTtlNamed" : "publicTtl")}</label><input id="publicWebTtl" type="number" min=${named ? "0" : "1"} max=${named ? "10080" : "480"} step="1" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
956
- <div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
1351
+ <div class=${fc("field", "ttl")}><label for="publicWebTtl">${tn(named ? "publicTtlNamed" : "publicTtl")}</label><input id="publicWebTtl" type="number" min=${named ? "0" : "1"} max=${named ? "10080" : "480"} step="1" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
1352
+ <div class=${fc("field", "protocol")}><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
957
1353
  </div>
958
1354
  ${named ? html`<p class="note" id="publicTtlNamedHint">${tn("publicTtlNamedHint")}</p>` : null}
959
- ${named || f.autoOpen ? html`<label class="check"><input id="publicAutoOpen" type="checkbox" checked=${f.autoOpen} onChange=${(e) => set("autoOpen")(e.target.checked)} /><span>${tn("publicAutoOpen")}</span><${Impact} schema=${schema} field="web.public_link.auto_open" /></label>
1355
+ ${named || f.autoOpen ? html`<label class=${fc("check", "autoOpen")}><input id="publicAutoOpen" type="checkbox" checked=${f.autoOpen} onChange=${(e) => set("autoOpen")(e.target.checked)} /><span>${tn("publicAutoOpen")}</span><${Impact} schema=${schema} field="web.public_link.auto_open" /></label>
960
1356
  <p class="note">${tn("publicAutoOpenHint")}</p><${Feedback} error=${errors.autoOpen} />` : null}
961
- </section>
962
- </fieldset>
963
- ${overPublicLink ? null : html`<${PublicTunnelToken} /><${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
964
- <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
1357
+ </section>`) : null}
1358
+ ${has("access") && !overPublicLink ? html`<${PublicTunnelToken} />` : null}
1359
+ ${has("access") ? html`<${OfficeSharesCard} />` : null}
1360
+ ${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
1361
+ ${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
1362
+ <label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
1363
+ <p class="note">${tn("webEchoHint")}</p>
1364
+ </section>`) : null}
1365
+ ${has("dashboard") ? lock(html`<section class="card"><h3>${tn("webDashboardTitle")}</h3>
1366
+ <label class=${fc("check", "log")}><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
1367
+ <p class="note">${tn("webLogTabHint")}</p>
1368
+ <label class=${fc("check", "usagePub")}><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
1369
+ <p class="note">${tn("webUsagePublicHint")}</p>
1370
+ </section>`) : null}
1371
+ ${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
965
1372
  <p class="note">${tn("webReadOnlyLead")}</p>
966
1373
  <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
967
1374
  <p class="note">${tn("webSearchHint")}</p>
968
- </section>
969
- <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${() => review()}>${tn("reviewChanges")}</button>
970
- ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
1375
+ </section>` : null}
971
1376
  </div>`;
972
1377
  }
973
1378
 
974
- /** Developer: the fleet model as YAML or JSON, to read, copy, download — or edit and apply as one operation. */
1379
+ /**
1380
+ * Developer: the fleet model as YAML or JSON, to read, copy, download — or edit. #2347: an edit's Apply stages its
1381
+ * writes in the Save bar like every form's change (Save writes them, in order, then one apply; Discard drops them),
1382
+ * and the editor keeps the edit while they wait — or comes back to it after a Save that did not land.
1383
+ */
975
1384
  function Developer({ ctx }) {
976
- const [fmt, setFmt] = useState("yaml");
977
- const [editing, setEditing] = useState(false);
1385
+ const mine = [...ctx.staged].find(([k]) => isYamlKey(k));
1386
+ const held = mine && mine[1].yaml ? mine[1].yaml : null;
1387
+ const heldCount = [...ctx.staged.keys()].filter(isYamlKey).length;
1388
+ const [fmt, setFmt] = useState(held ? held.fmt : "yaml");
1389
+ const [editing, setEditing] = useState(!!held);
978
1390
  const model = fleetModel(ctx.fleet);
979
1391
  const text = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
980
- const [draft, setDraft] = useState(text);
1392
+ const [draft, setDraft] = useState(held ? held.text : text);
981
1393
  const [msg, setMsg] = useState(null);
982
1394
  useEffect(() => { if (!editing) setDraft(text); }, [text, editing]);
1395
+ // The edit as staged: one that goes (its Save began, or Discard) leaves the editor on what is in force; one that comes
1396
+ // back (a Save that did not land all of it) is the editor's text again.
1397
+ const heldText = held ? held.text : null;
1398
+ const was = useRef(heldText);
1399
+ useEffect(() => {
1400
+ if (heldText !== null && heldText !== was.current) { setFmt(held.fmt); setDraft(heldText); setEditing(true); }
1401
+ else if (heldText === null && was.current !== null) { setEditing(false); setMsg(null); }
1402
+ was.current = heldText;
1403
+ }, [heldText]);
1404
+ // The forms' own unsaved changes to the same settings come first: the editor is for reading until they are saved.
1405
+ const blocked = formsStaged(ctx.staged);
983
1406
  const copy = () => { try { navigator.clipboard.writeText(text).then(() => setMsg({ text: tn("copied") }), () => setMsg({ error: tn("failed") })); } catch { setMsg({ error: tn("failed") }); } };
984
1407
  const download = () => {
985
1408
  const a = document.createElement("a");
@@ -990,38 +1413,56 @@ function Developer({ ctx }) {
990
1413
  let parsed;
991
1414
  try { parsed = fmt === "yaml" ? fromYaml(draft) : JSON.parse(draft || "{}"); } catch (e) { setMsg({ error: e.message }); return; }
992
1415
  if (!parsed || typeof parsed !== "object") { setMsg({ error: tn("expectedMapping") }); return; }
1416
+ if (formsStaged(ctx.staged)) { setMsg({ error: tn("devFormsUnsaved") }); return; }
993
1417
  const requests = fullModelRequests(parsed, ctx.fleet);
1418
+ // An Apply again replaces the edit staged before: what it writes is this text's difference from what is in force.
1419
+ for (const k of ctx.staged.keys()) if (isYamlKey(k)) ctx.unstage(k);
1420
+ was.current = null; // its own Apply: not an edit that went (the editor stays on it)
994
1421
  if (!requests.length) { setMsg({ text: tn("noChanges") }); return; }
995
- if (operationActive()) { setMsg({ error: tn("applyBusyLocal") }); return; }
996
- // The whole edit is one operation: its writes in order, then the apply — the same runner as every Apply.
997
- if (startOperation(requests.map((r) => ({ label: `${r.method} ${decodeURIComponent(r.url.replace("/api/settings/", ""))}`, impact: requestImpact(ctx.schema, r), request: r })))) {
998
- setEditing(false); setMsg(null);
1422
+ const yaml = { fmt, text: draft };
1423
+ let staged = 0;
1424
+ for (const [i, r] of requests.entries()) {
1425
+ if (ctx.stage(`${YAML_KEY}${i}`, { label: `${r.method} ${decodeURIComponent(r.url.replace("/api/settings/", ""))}`, impact: requestImpact(ctx.schema, r), request: r, yaml }, true)) staged++;
999
1426
  }
1427
+ setMsg(staged ? { text: tn("devStaged", staged) } : null);
1000
1428
  };
1001
1429
  return html`<section class="card s-dev">
1002
1430
  <div class="s-dev-bar">
1003
1431
  <div class="seg-inline" role="group" aria-label=${tn("format")}>
1004
1432
  ${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
1005
- <button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
1433
+ <button type="button" class="btn btn-sm" disabled=${blocked && !editing} title=${blocked && !editing ? tn("devFormsUnsaved") : undefined} onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
1006
1434
  <button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
1007
1435
  <button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
1008
1436
  </div>
1009
1437
  ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
1010
- <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
1438
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${blocked} onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
1011
1439
  : html`<pre class="s-yaml">${text}</pre>`}
1440
+ ${blocked ? html`<p class="note s-dev-blocked" role="status">${tn("devFormsUnsaved")}</p>`
1441
+ : held && !msg ? html`<p class="note s-dev-held" role="status">${tn("devStaged", heldCount)}</p>` : null}
1012
1442
  ${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
1013
1443
  </section>`;
1014
1444
  }
1015
1445
 
1016
- /** Staged, not applied: the count, what it will cost, Discard and Apply. */
1017
- function PendingBar({ staged, busy, onApply, onDiscard }) {
1018
- const kinds = [...new Set([...staged.values()].map((c) => c.impact))];
1446
+ /**
1447
+ * #2347: the section's Save. Shown while anything is unsaved: how many changes, what they will cost, Discard and Save —
1448
+ * Save waits while a field is invalid ("Fix 1 field to save").
1449
+ */
1450
+ function PendingBar({ staged, busy, onApply, onDiscard, section }) {
1451
+ const changes = [...staged.values()];
1452
+ const kinds = [...new Set(changes.filter((c) => !c.invalid).map((c) => c.impact))];
1453
+ const invalid = changes.filter((c) => c.invalid).length;
1454
+ // #2382 review: a field to fix in another section is named, and the message goes there (its field focused).
1455
+ const elsewhere = changes.find((c) => c.invalid && c.section && c.section !== section);
1456
+ const count = changes.reduce((n, c) => n + (Number.isInteger(c.count) && c.count > 0 ? c.count : 1), 0);
1019
1457
  return html`<div data-trust="panel-controls" class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
1020
- <strong>${tn("changesPending", staged.size)}</strong>
1458
+ <strong>${tn("changesPending", count)}</strong>
1021
1459
  <span class="note">${kinds.map(impactText).join(" · ")}</span>
1022
- <span class="grow"></span>
1023
- <button type="button" class="btn" onClick=${onDiscard} disabled=${busy}>${tn("discard")}</button>
1024
- <button type="button" class="btn btn-primary" onClick=${onApply} disabled=${busy} title=${busy ? tn("applyBusyLocal") : undefined}>${tn("applyChanges")}</button>
1460
+ <span class="s-pending-actions">
1461
+ ${!invalid ? null : elsewhere ? html`<button type="button" class="link-btn feedback error s-fix" role="status" onClick=${() => goToTarget(elsewhere.section, elsewhere.target)}>${tn("fixToSaveIn", invalid, tn(`sec_${elsewhere.section}`))}</button>`
1462
+ : html`<span class="feedback error s-fix" role="status">${tn("fixToSave", invalid)}</span>`}
1463
+ <button type="button" class="btn" onClick=${onDiscard} disabled=${busy}>${tn("discard")}</button>
1464
+ <button type="button" class="btn btn-primary" onClick=${onApply} disabled=${busy || invalid > 0} title=${busy ? tn("applyBusyLocal") : undefined}>${tn("applyChanges")}</button>
1465
+ </span>
1025
1466
  </div>`;
1026
1467
  }
1027
1468