@songsid/agend 2.3.0-beta.22 → 2.3.0-beta.23

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 (256) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/agent-cli.js +4 -2
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +1 -0
  6. package/dist/agent-endpoint.js +4 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.d.ts +5 -0
  9. package/dist/agent-whats-new.js +16 -1
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/auth-api.d.ts +7 -0
  12. package/dist/auth-api.js +8 -2
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/codex-package.d.ts +30 -0
  15. package/dist/backend/codex-package.js +133 -0
  16. package/dist/backend/codex-package.js.map +1 -0
  17. package/dist/backend/codex.d.ts +2 -0
  18. package/dist/backend/codex.js +9 -2
  19. package/dist/backend/codex.js.map +1 -1
  20. package/dist/backend/kiro.d.ts +9 -1
  21. package/dist/backend/kiro.js +102 -1
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/opencode-permission.js +1 -1
  24. package/dist/backend/opencode-permission.js.map +1 -1
  25. package/dist/backend/opencode.js +2 -1
  26. package/dist/backend/opencode.js.map +1 -1
  27. package/dist/binary-probe.js +1 -1
  28. package/dist/bug-report-cli.js +1 -1
  29. package/dist/bundled-skills.d.ts +7 -3
  30. package/dist/bundled-skills.js +16 -7
  31. package/dist/bundled-skills.js.map +1 -1
  32. package/dist/channel/adapters/discord.js +3 -3
  33. package/dist/channel/adapters/discord.js.map +1 -1
  34. package/dist/channel/mcp-server.js +12 -4
  35. package/dist/channel/mcp-server.js.map +1 -1
  36. package/dist/channel/mcp-tools.d.ts +3 -1
  37. package/dist/channel/mcp-tools.js +36 -0
  38. package/dist/channel/mcp-tools.js.map +1 -1
  39. package/dist/child-process.d.ts +21 -0
  40. package/dist/child-process.js +54 -0
  41. package/dist/child-process.js.map +1 -0
  42. package/dist/cli.js +167 -182
  43. package/dist/cli.js.map +1 -1
  44. package/dist/command-table.d.ts +1 -1
  45. package/dist/command-table.js +1 -1
  46. package/dist/command-table.js.map +1 -1
  47. package/dist/config-validator.js +20 -1
  48. package/dist/config-validator.js.map +1 -1
  49. package/dist/config.js +3 -1
  50. package/dist/config.js.map +1 -1
  51. package/dist/daemon.d.ts +8 -0
  52. package/dist/daemon.js +31 -8
  53. package/dist/daemon.js.map +1 -1
  54. package/dist/darwin-memory.js +1 -1
  55. package/dist/doctor.d.ts +5 -0
  56. package/dist/doctor.js +42 -0
  57. package/dist/doctor.js.map +1 -1
  58. package/dist/export-import.js +1 -1
  59. package/dist/export-import.js.map +1 -1
  60. package/dist/fleet-level-config.d.ts +1 -1
  61. package/dist/fleet-level-config.js +2 -0
  62. package/dist/fleet-level-config.js.map +1 -1
  63. package/dist/fleet-manager.d.ts +45 -0
  64. package/dist/fleet-manager.js +301 -21
  65. package/dist/fleet-manager.js.map +1 -1
  66. package/dist/full-restart.d.ts +5 -5
  67. package/dist/full-restart.js +5 -5
  68. package/dist/full-restart.js.map +1 -1
  69. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  70. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +7 -8
  71. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  72. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
  73. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
  74. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +16 -3
  75. package/dist/general-knowledge/steering/core-rules.md +1 -1
  76. package/dist/instance-config-impact.js +10 -3
  77. package/dist/instance-config-impact.js.map +1 -1
  78. package/dist/instance-lifecycle.js +4 -4
  79. package/dist/locale.js +118 -4
  80. package/dist/locale.js.map +1 -1
  81. package/dist/login-flows.js +1 -1
  82. package/dist/login-shell.js +3 -1
  83. package/dist/login-shell.js.map +1 -1
  84. package/dist/ls-rows.js +1 -1
  85. package/dist/needs-you-hub.d.ts +98 -1
  86. package/dist/needs-you-hub.js +313 -44
  87. package/dist/needs-you-hub.js.map +1 -1
  88. package/dist/needs-you-live.d.ts +5 -2
  89. package/dist/needs-you-live.js +6 -3
  90. package/dist/needs-you-live.js.map +1 -1
  91. package/dist/needs-you.d.ts +58 -3
  92. package/dist/needs-you.js +36 -3
  93. package/dist/needs-you.js.map +1 -1
  94. package/dist/outbound-schemas.d.ts +3 -3
  95. package/dist/public-link-progress.d.ts +12 -1
  96. package/dist/public-link-progress.js +5 -10
  97. package/dist/public-link-progress.js.map +1 -1
  98. package/dist/public-tunnel-config.d.ts +5 -0
  99. package/dist/public-tunnel-config.js +45 -1
  100. package/dist/public-tunnel-config.js.map +1 -1
  101. package/dist/public-web-link.js +6 -5
  102. package/dist/public-web-link.js.map +1 -1
  103. package/dist/quickstart.js +14 -8
  104. package/dist/quickstart.js.map +1 -1
  105. package/dist/resource-report.js +1 -1
  106. package/dist/scheduler/task-board.d.ts +41 -1
  107. package/dist/scheduler/task-board.js +150 -4
  108. package/dist/scheduler/task-board.js.map +1 -1
  109. package/dist/sd-notify.js +2 -1
  110. package/dist/sd-notify.js.map +1 -1
  111. package/dist/service-installer.d.ts +69 -0
  112. package/dist/service-installer.js +181 -0
  113. package/dist/service-installer.js.map +1 -1
  114. package/dist/service-owner.d.ts +44 -0
  115. package/dist/service-owner.js +48 -0
  116. package/dist/service-owner.js.map +1 -0
  117. package/dist/settings-api.d.ts +4 -0
  118. package/dist/settings-api.js +21 -3
  119. package/dist/settings-api.js.map +1 -1
  120. package/dist/settings-change.js +85 -2
  121. package/dist/settings-change.js.map +1 -1
  122. package/dist/settings-effect.js +2 -0
  123. package/dist/settings-effect.js.map +1 -1
  124. package/dist/settings-http-confirmation.d.ts +2 -0
  125. package/dist/settings-http-confirmation.js +3 -2
  126. package/dist/settings-http-confirmation.js.map +1 -1
  127. package/dist/settings-request-capability.d.ts +4 -1
  128. package/dist/settings-request-capability.js +11 -3
  129. package/dist/settings-request-capability.js.map +1 -1
  130. package/dist/setup-host.js +1 -1
  131. package/dist/setup-wizard.js +1 -1
  132. package/dist/setup-wizard.js.map +1 -1
  133. package/dist/task-board-summary.d.ts +101 -0
  134. package/dist/task-board-summary.js +177 -0
  135. package/dist/task-board-summary.js.map +1 -0
  136. package/dist/task-board-waker.d.ts +8 -1
  137. package/dist/task-board-waker.js +15 -6
  138. package/dist/task-board-waker.js.map +1 -1
  139. package/dist/tmux-control.js +1 -1
  140. package/dist/tmux-control.js.map +1 -1
  141. package/dist/tmux-exec-helper.d.ts +5 -1
  142. package/dist/tmux-exec-helper.js +13 -21
  143. package/dist/tmux-exec-helper.js.map +1 -1
  144. package/dist/tmux-manager.js +1 -1
  145. package/dist/tmux-read.js +1 -1
  146. package/dist/tmux-read.js.map +1 -1
  147. package/dist/topic-commands.d.ts +3 -0
  148. package/dist/topic-commands.js +29 -18
  149. package/dist/topic-commands.js.map +1 -1
  150. package/dist/transcript-monitor.js +5 -0
  151. package/dist/transcript-monitor.js.map +1 -1
  152. package/dist/transcript-sources.d.ts +2 -0
  153. package/dist/transcript-sources.js +4 -0
  154. package/dist/transcript-sources.js.map +1 -1
  155. package/dist/tunnel/cloudflared-install.js +3 -1
  156. package/dist/tunnel/cloudflared-install.js.map +1 -1
  157. package/dist/tunnel/cloudflared.d.ts +1 -1
  158. package/dist/tunnel/cloudflared.js +51 -10
  159. package/dist/tunnel/cloudflared.js.map +1 -1
  160. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  161. package/dist/tunnel/named-diagnostics.js +52 -0
  162. package/dist/tunnel/named-diagnostics.js.map +1 -0
  163. package/dist/tunnel/types.d.ts +2 -1
  164. package/dist/tunnel/types.js.map +1 -1
  165. package/dist/types.d.ts +9 -0
  166. package/dist/ui/app-effects.js +10 -4
  167. package/dist/ui/chat-strings.js +3 -0
  168. package/dist/ui/chat-thread.js +14 -7
  169. package/dist/ui/effect-trial-offer.js +2 -31
  170. package/dist/ui/effects-waker.js +2 -70
  171. package/dist/ui/fleet-task-card.js +5 -5
  172. package/dist/ui/fleet-task-common.js +41 -7
  173. package/dist/ui/fleet-task-work.js +107 -0
  174. package/dist/ui/fleet-tasks.js +46 -12
  175. package/dist/ui/image-lightbox.js +39 -1
  176. package/dist/ui/image-zoom.js +242 -0
  177. package/dist/ui/panel-chat.js +24 -13
  178. package/dist/ui/panel-details.js +2 -0
  179. package/dist/ui/panel-fleet.js +6 -2
  180. package/dist/ui/panel-needs.js +21 -6
  181. package/dist/ui/panel-settings.js +35 -13
  182. package/dist/ui/preview-panel.js +2 -1
  183. package/dist/ui/search-dialog.js +2 -1
  184. package/dist/ui/settings-appearance.js +33 -8
  185. package/dist/ui/settings-dialogs.js +33 -17
  186. package/dist/ui/settings-discord.js +6 -2
  187. package/dist/ui/settings-guests.js +2 -1
  188. package/dist/ui/settings-model.js +2 -11
  189. package/dist/ui/settings-mods.js +6 -3
  190. package/dist/ui/settings-strings.js +2 -0
  191. package/dist/ui/settings-token.js +3 -1
  192. package/dist/ui/settings-web-authority.js +6 -3
  193. package/dist/ui/settings-wizard.js +13 -7
  194. package/dist/ui/shared/app-i18n.js +2 -2
  195. package/dist/ui/shared/app-needs.js +2 -2
  196. package/dist/ui/shared/app-store.js +2 -2
  197. package/dist/ui/shared/app.css +80 -9
  198. package/dist/ui/shared/app.js +5 -1
  199. package/dist/ui/shared/effect-app-runtime.js +7 -5
  200. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  201. package/dist/ui/shared/effect-cursor.js +3 -2
  202. package/dist/ui/shared/effect-display-metadata.js +39 -0
  203. package/dist/ui/shared/effect-registry.js +4 -2
  204. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  205. package/dist/ui/shared/effect-stock-trust.js +1 -1
  206. package/dist/ui/shared/feature-flags.js +127 -0
  207. package/dist/ui/shared/identity-color.js +6 -3
  208. package/dist/ui/shared/instance-nav.js +3 -2
  209. package/dist/ui/shared/text-undo.js +210 -0
  210. package/dist/ui/shared/ui-dialog.js +4 -3
  211. package/dist/ui/shared/ui-icons.js +2 -0
  212. package/dist/update-dispatch.js +1 -1
  213. package/dist/update-launch.d.ts +1 -1
  214. package/dist/update-launch.js +1 -2
  215. package/dist/update-launch.js.map +1 -1
  216. package/dist/usage/providers.js +1 -1
  217. package/dist/usage/providers.js.map +1 -1
  218. package/dist/view-api.js +1 -1
  219. package/dist/web-api.d.ts +6 -0
  220. package/dist/web-api.js +152 -14
  221. package/dist/web-api.js.map +1 -1
  222. package/dist/web-appearance-tools.d.ts +1 -0
  223. package/dist/web-appearance-tools.js +7 -3
  224. package/dist/web-appearance-tools.js.map +1 -1
  225. package/dist/web-appearance.d.ts +4 -10
  226. package/dist/web-appearance.js +4 -3
  227. package/dist/web-appearance.js.map +1 -1
  228. package/dist/web-chat-history.d.ts +14 -0
  229. package/dist/web-chat-history.js +26 -1
  230. package/dist/web-chat-history.js.map +1 -1
  231. package/dist/web-experimental-http.d.ts +2 -0
  232. package/dist/web-experimental-http.js +12 -0
  233. package/dist/web-experimental-http.js.map +1 -0
  234. package/dist/web-experimental.d.ts +11 -0
  235. package/dist/web-experimental.js +49 -0
  236. package/dist/web-experimental.js.map +1 -0
  237. package/dist/web-guest-access.d.ts +1 -0
  238. package/dist/web-guest-access.js +4 -2
  239. package/dist/web-guest-access.js.map +1 -1
  240. package/dist/web-mod-store.d.ts +2 -0
  241. package/dist/web-mod-store.js +10 -0
  242. package/dist/web-mod-store.js.map +1 -1
  243. package/dist/web-preview.d.ts +2 -0
  244. package/dist/web-preview.js +2 -0
  245. package/dist/web-preview.js.map +1 -1
  246. package/dist/web-settings-patch.d.ts +2 -1
  247. package/dist/web-settings-patch.js +5 -1
  248. package/dist/web-settings-patch.js.map +1 -1
  249. package/dist/web-settings-tools.d.ts +1 -0
  250. package/dist/web-settings-tools.js +14 -10
  251. package/dist/web-settings-tools.js.map +1 -1
  252. package/dist/web-terminal.js +1 -1
  253. package/dist/web-theme-store.d.ts +2 -2
  254. package/dist/web-theme-store.js +6 -3
  255. package/dist/web-theme-store.js.map +1 -1
  256. package/package.json +1 -1
@@ -276,6 +276,10 @@ function Config({ lease }) {
276
276
  // Each save owns the form's busy state only while it is the latest: an older save that is decided later (it waited
277
277
  // for an admin's confirmation) never unlocks the form under a newer one still on its way (#1453 review).
278
278
  const saves = useRef(0);
279
+ // #2342: each project root's row keeps its own key — keyed by position, removing one shifted the texts below into
280
+ // the boxes above (each box's text rewritten, its undo history lost).
281
+ const rootKeys = useRef({ n: 0, list: [] });
282
+ const rootKey = (i) => { const k = rootKeys.current; while (k.list.length <= i) k.list.push(++k.n); return k.list[i]; };
279
283
  useEffect(() => {
280
284
  if (!state || !state.data) return;
281
285
  const c = state.data, ch = c.channel || {}, acc = ch.access || {};
@@ -314,9 +318,9 @@ function Config({ lease }) {
314
318
  <section class="card"><h3>${t("fleet.defaults")}</h3>
315
319
  <div class="field"><label for="cfg-be">${t("fleet.backend")}</label><${BackendSelect} id="cfg-be" value=${form.backend} onChange=${(v) => setForm({ ...form, backend: v })} /></div></section>
316
320
  <section class="card"><h3>${t("fleet.roots")}</h3>
317
- ${form.roots.map((r, i) => html`<div key=${i} class="field-row"><input value=${r} placeholder="/path/to/projects" aria-label=${t("fleet.roots")}
321
+ ${form.roots.map((r, i) => html`<div key=${rootKey(i)} class="field-row"><input value=${r} placeholder="/path/to/projects" aria-label=${t("fleet.roots")}
318
322
  onInput=${(e) => { const roots = [...form.roots]; roots[i] = e.target.value; setForm({ ...form, roots }); }} />
319
- <button type="button" class="icon-btn" aria-label=${t("fleet.removeRoot")} title=${t("fleet.removeRoot")} onClick=${() => setForm({ ...form, roots: form.roots.filter((_, j) => j !== i) })}><${Icon} name="close" size=${16} /></button></div>`)}
323
+ <button type="button" class="icon-btn" aria-label=${t("fleet.removeRoot")} title=${t("fleet.removeRoot")} onClick=${() => { rootKeys.current.list.splice(i, 1); setForm({ ...form, roots: form.roots.filter((_, j) => j !== i) }); }}><${Icon} name="close" size=${16} /></button></div>`)}
320
324
  <button type="button" class="btn btn-ghost" onClick=${() => setForm({ ...form, roots: [...form.roots, ""] })}><${Icon} name="plus" size=${16} />${t("fleet.addRoot")}</button></section>
321
325
  <div class="save-row"><button type="button" class="btn btn-primary" disabled=${saving} onClick=${save}>${t("fleet.save")}</button><span class="note">${t("fleet.saveNote")}</span></div>`}</${Loaded}>`;
322
326
  }
@@ -25,7 +25,8 @@ import { openSignIn, signInOffered } from "./backend-signin.js";
25
25
  register("needs", {
26
26
  en: {
27
27
  title: "Needs you", empty: "Nothing needs you right now", emptyHint: "When an agent waits for you, it shows here — and in your chat app's General.", emptyHintWeb: "When an agent waits for you, it shows here.",
28
- open: "Open chat", signIn: "Sign in", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
28
+ open: "Open chat", openCard: "Open the card", noAssignee: "Task board (nobody assigned)", signIn: "Sign in",
29
+ gap: "not wrapped up: {0}", gapUnchecked: "{0} checklist item(s) unchecked", gapNoResult: "no result", gapJoin: ", ", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
29
30
  ageNow: "just now", ageMin: "{0} min", ageHour: "{0} h", ageDay: "{0} d",
30
31
  notifyTitle: "Notify me on this device", notifyOn: "Notifications are on for this browser while a tab is open.",
31
32
  notifyOff: "Get a desktop notification when something new needs you while this tab is in the background.",
@@ -37,7 +38,8 @@ register("needs", {
37
38
  },
38
39
  "zh-TW": {
39
40
  title: "等你處理", empty: "目前沒有需要你處理的事", emptyHint: "agent 在等你時,會出現在這裡——也會出現在聊天 app 的 General。", emptyHintWeb: "agent 在等你時,會出現在這裡。",
40
- open: "開啟聊天", signIn: "登入", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
41
+ open: "開啟聊天", openCard: "打開卡片", noAssignee: "任務看板(沒有負責人)", signIn: "登入",
42
+ gap: "還沒補齊:{0}", gapUnchecked: "還有 {0} 項沒勾", gapNoResult: "沒有產出總結", gapJoin: "、", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
41
43
  ageNow: "剛剛", ageMin: "{0} 分鐘", ageHour: "{0} 小時", ageDay: "{0} 天",
42
44
  notifyTitle: "在這台裝置通知我", notifyOn: "這個瀏覽器的通知已開啟(需要開著分頁)。",
43
45
  notifyOff: "分頁在背景時,有新的事等你處理就跳出桌面通知。",
@@ -84,9 +86,21 @@ const setClaim = (kind, key, value) => claims.set((c) => {
84
86
  return { ...c, [kind]: next };
85
87
  });
86
88
 
87
- const ICON = { prompt: "chat", awaiting_input: "clock", instance: "alert", delivery: "send" };
89
+ const ICON = { prompt: "chat", awaiting_input: "clock", instance: "alert", delivery: "send", task: "tasks" };
88
90
  const BAD = new Set(["crashed", "delivery_failed", "hang", "exited"]);
89
91
 
92
+ /**
93
+ * An item's line. A task board card's is said here, in the page's language (#2246 step 3): its number and title, then
94
+ * its question or what it lacks; anything else is the server's own words.
95
+ */
96
+ function detailOf(item) {
97
+ const c = item.type === "task" ? item.card : null;
98
+ if (!c || !c.title) return item.detail;
99
+ const lacks = c.gap ? [c.gap.unchecked ? tn("gapUnchecked", c.gap.unchecked) : "", c.gap.noResult ? tn("gapNoResult") : ""].filter(Boolean).join(tn("gapJoin")) : "";
100
+ const tail = c.question || (lacks ? tn("gap", lacks) : "");
101
+ return `${c.code} ${c.title}${tail ? ` — ${tail}` : ""}`;
102
+ }
103
+
90
104
  /** "3 min", "2 h": the age shown beside an item (refreshed by a display timer, never by a read). */
91
105
  export function age(since, nowMs) {
92
106
  const minutes = Math.max(0, Math.floor((nowMs - since) / 60_000));
@@ -129,7 +143,7 @@ export function NeedsPanel({ navKey }) {
129
143
  <div data-trust="panel-content" class="panel-body"><div class="col">
130
144
  ${!items.length ? html`<${Empty} icon="check" title=${tn("empty")} hint=${tn(chatApp ? "emptyHint" : "emptyHintWeb")} />`
131
145
  : groups.map((g) => html`<section key=${g.instance} class="n-group" aria-label=${g.instance}>
132
- <h2 class="n-group-head"><a href=${chatPath(g.instance)} title=${g.instance}>${nameFor(g.instance)}</a></h2>
146
+ <h2 class="n-group-head">${g.instance ? html`<a href=${chatPath(g.instance)} title=${g.instance}>${nameFor(g.instance)}</a>` : tn("noAssignee")}</h2>
133
147
  ${g.items.map((item) => html`<${NeedsItem} key=${item.id} item=${item} nowMs=${nowMs} owner=${owner} />`)}
134
148
  </section>`)}
135
149
  <${NotifyToggle} />
@@ -180,14 +194,15 @@ function NeedsItem({ item, nowMs, owner }) {
180
194
  <span class="n-icon"><${Icon} name=${ICON[item.type] || "alert"} size=${18} /></span>
181
195
  <div class="n-body">
182
196
  <div class="n-title"><strong>${reasonText(item)}</strong><span class="n-age">${age(item.since, nowMs)}</span></div>
183
- ${item.detail ? html`<p class="n-detail">${item.detail}</p>` : null}
197
+ ${detailOf(item) ? html`<p class="n-detail">${detailOf(item)}</p>` : null}
184
198
  <div class="n-actions">
185
199
  ${item.type === "prompt" && Array.isArray(item.actions) ? item.actions.map((a) => html`<button key=${a.id} type="button" class="btn btn-sm"
186
200
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
187
201
  ${item.type === "delivery" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
188
202
  <${Icon} name="check" size=${14} />${ack === "busy" ? tn("acknowledging") : tn("acknowledge")}</button>` : null}
189
203
  ${item.reason === "auth_paused" && item.signIn && signInOffered() ? html`<button type="button" class="btn btn-sm btn-primary" onClick=${() => openSignIn(item.signIn)}>${tn("signIn")}</button>` : null}
190
- <a class="btn btn-sm btn-ghost" href=${chatPath(item.instance)}>${tn("open")}</a>
204
+ ${item.type === "task" && item.card ? html`<a class="btn btn-sm" href=${`/ui/fleet?card=${encodeURIComponent(item.card.code)}`}>${tn("openCard")}</a>` : null}
205
+ ${item.instance ? html`<a class="btn btn-sm btn-ghost" href=${chatPath(item.instance)}>${tn("open")}</a>` : null}
191
206
  </div>
192
207
  </div>
193
208
  </article>`;
@@ -175,7 +175,7 @@ export function SettingsPanel({ route, navKey }) {
175
175
  const applied = (pendingChanges || []).filter((p) => p.state === "applied").map((p) => p.id).join(",");
176
176
  useEffect(() => { if (applied) reload(); }, [applied]);
177
177
 
178
- const stage = (key, change) => {
178
+ const stage = (key, change, quiet = false) => {
179
179
  setStaged((s) => {
180
180
  const m = new Map(s);
181
181
  const prev = m.get(key);
@@ -186,7 +186,7 @@ export function SettingsPanel({ route, navKey }) {
186
186
  m.set(key, { ...change, stageKey: key });
187
187
  return m;
188
188
  });
189
- toast(tn("queued", change.label));
189
+ if (!quiet) toast(tn("queued", change.label));
190
190
  };
191
191
  const unstage = (key) => setStaged((s) => { if (!s.has(key)) return s; const m = new Map(s); m.delete(key); return m; });
192
192
  const discard = () => { dropSecrets(staged); setStaged(new Map()); reload(); };
@@ -818,11 +818,21 @@ function WebSection({ ctx, staged }) {
818
818
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
819
819
  }), []);
820
820
  const text = (v) => (v === null || v === undefined ? "" : String(v));
821
- const [f, setF] = useState(() => ({
822
- echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
823
- abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
824
- termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
825
- }));
821
+ const [f, setF] = useState(() => {
822
+ // Settings sections share one draft. Reopening Web must show it as well as preserve it in the Apply bar, so a
823
+ // subsequent field edit keeps earlier edits. The original disk values remain the diff/confirmation baseline.
824
+ const request = staged.get("web")?.request;
825
+ const pending = request?.method === "PUT" && request.url === "/api/settings/fleet/web" ? request.body || {} : {};
826
+ const pub = pending.public_link || {}, session = pending.session?.local || {}, terminal = pending.web_terminal || {};
827
+ const pick = (obj, key, fallback) => Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
828
+ return {
829
+ 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),
830
+ 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),
831
+ hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)),
832
+ abs: text(pick(session, "absolute_hours", original.session.absolute_hours)), idle: text(pick(session, "idle_hours", original.session.idle_hours)),
833
+ termOn: pick(terminal, "enabled", original.terminal.enabled) !== false, termTtl: text(pick(terminal, "ttl_minutes", original.terminal.ttl_minutes)),
834
+ };
835
+ });
826
836
  const [msg, setMsg] = useState(null);
827
837
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
828
838
  // Empty = the default (null in the write: the key is removed from fleet.yaml).
@@ -840,9 +850,14 @@ function WebSection({ ctx, staged }) {
840
850
  termTtl: value(f.termTtl) !== null && (!Number.isInteger(value(f.termTtl)) || value(f.termTtl) < 1 || value(f.termTtl) > 20) ? "webTerminalTtlRange" : "",
841
851
  };
842
852
  const ready = !overPublicLink && !Object.values(errors).some(Boolean);
843
- const review = () => {
844
- if (!ready) return;
845
- const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol, hostname: f.hostname, tunnel_token_env: f.hostname ? "AGEND_CLOUDFLARE_TUNNEL_TOKEN" : "", origin_port: Number(f.originPort) }, original.pub);
853
+ const review = (quiet = false) => {
854
+ // Invalid intermediate text must not leave an older valid draft available to Apply.
855
+ if (!ready) { if (staged.has("web")) ctx.unstage("web"); setMsg(null); return; }
856
+ // Keep an existing secret reference, including a custom one. Only a newly supplied hostname without any
857
+ // reference adopts the standard env key; mounting or editing an unrelated field never rewrites credentials.
858
+ const tokenEnv = f.hostname === original.pub.hostname ? original.pub.tunnel_token_env
859
+ : f.hostname ? original.pub.tunnel_token_env || "AGEND_CLOUDFLARE_TUNNEL_TOKEN" : "";
860
+ const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol, hostname: f.hostname, tunnel_token_env: tokenEnv, origin_port: Number(f.originPort) }, original.pub);
846
861
  const sessionPatch = changedFields({ absolute_hours: value(f.abs), idle_hours: value(f.idle) }, original.session);
847
862
  // The checkbox shows the default (on) for an unset key: only a real toggle is written; on again = back to the default.
848
863
  const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
@@ -865,10 +880,16 @@ function WebSection({ ctx, staged }) {
865
880
  ];
866
881
  if (fields.length) {
867
882
  ctx.stage("web", { label: tn("sec_web"), impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
868
- request: { method: "PUT", url: "/api/settings/fleet/web", body } });
883
+ request: { method: "PUT", url: "/api/settings/fleet/web", body } }, quiet);
869
884
  } else if (staged.has("web")) ctx.unstage("web");
870
- setMsg(tn("readyApply"));
885
+ setMsg(fields.length ? tn("readyApply") : null);
871
886
  };
887
+ // This form already has validation and a single shared Web draft. Valid edits join that draft immediately;
888
+ // Apply, its confirmations and the leave guard still own all HTTP writes. Quiet staging avoids a toast per key.
889
+ // Returning from another Settings section remounts this form: its unchanged initial fields must not erase the
890
+ // parent's already-staged Web draft. Only an actual field edit replaces or removes that draft.
891
+ const formMounted = useRef(false);
892
+ useEffect(() => { if (!formMounted.current) { formMounted.current = true; return; } review(true); }, [f]);
872
893
  const shown = (path) => {
873
894
  const [top, key] = path.split(".");
874
895
  const v = (top === "web" ? web : term)[key];
@@ -920,7 +941,7 @@ function WebSection({ ctx, staged }) {
920
941
  <p class="note">${tn("webReadOnlyLead")}</p>
921
942
  <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>
922
943
  </section>
923
- <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
944
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${() => review()}>${tn("reviewChanges")}</button>
924
945
  ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
925
946
  </div>`;
926
947
  }
@@ -1062,6 +1083,7 @@ function PublicTunnelToken() {
1062
1083
  try {
1063
1084
  const prepared = await api(`${base}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(clear ? { clear: true } : { secret: value }) });
1064
1085
  if (!lease.current()) return;
1086
+ if (!prepared.ok && prepared.body?.error_code === "invalid_tunnel_token") { setMessage(tn("namedTokenInvalid")); return; }
1065
1087
  if (!prepared.ok) throw Error("refused");
1066
1088
  const applied = await confirmedWrite(`${base}/apply`, { method: "POST", key, label: tn(clear ? "namedTokenClear" : "namedTokenSet"), body: { verification_id: prepared.body?.verification_id, idempotency_key: key } });
1067
1089
  let body = applied.body || {};
@@ -16,7 +16,8 @@ import { Icon } from "/assets/ui-icons.js";
16
16
  import "./chat-render.js";
17
17
  import "./preview.js";
18
18
  import { loadHtmlAttachment } from "./html-attachment.js";
19
- import { EffectTrialPanel } from "./effect-trial-panel.js";
19
+ import { experimentalComponent, useCanvasWorker } from "/assets/feature-flags.js";
20
+ const EffectTrialPanel = experimentalComponent(() => import("./effect-trial-panel.js"), m => m.EffectTrialPanel);
20
21
  import { chatTime, onDayChange } from "./chat-time.js";
21
22
 
22
23
  const R = () => globalThis.AgendChatRender;
@@ -5,6 +5,7 @@
5
5
  // agents, a task — opens here as the index's copy (GET /ui/search/context). Text only: no innerHTML, no style attribute.
6
6
  import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
7
7
  import { t, register } from "/assets/app-i18n.js";
8
+ import { recordEdit } from "/assets/text-undo.js";
8
9
  import { appStore, useStore } from "/assets/app-store.js";
9
10
  import { navigate } from "/assets/app-nav.js";
10
11
  import { chatPath, fleetPath } from "/assets/app-route.js";
@@ -286,7 +287,7 @@ export function SearchDialog({ preset = {}, onClose }) {
286
287
  <div class="search-body" ref=${list}>
287
288
  ${detail ? html`<${Detail} detail=${detail} q=${q} label=${label} onBack=${() => { setDetail(null); input.current && input.current.focus(); }} onClose=${onClose} />`
288
289
  : html`<${Results} q=${q} res=${res} groups=${groups} active=${active} setActive=${setActive} open=${open} label=${label} filtered=${activeFilters > 0}
289
- recent=${recent} pick=${(s) => { setQ(s); input.current && input.current.focus(); }} clearRecent=${() => { forgetRecent(); setRecent([]); }} />`}
290
+ recent=${recent} pick=${(s) => { recordEdit(input.current); setQ(s); input.current && input.current.focus(); }} clearRecent=${() => { forgetRecent(); setRecent([]); }} />`}
290
291
  </div>
291
292
  <div class="search-foot" aria-hidden="true">${t("search.keys")}</div>
292
293
  </div></dialog>`;
@@ -2,10 +2,12 @@
2
2
  // versions, the pictures and fonts it may name, and this device's off switch. Settings itself is always drawn stock
3
3
  // (owner-css.js removes the link here), so a stylesheet that hides things can always be fixed from this page.
4
4
  import { html, useEffect, useRef, useState } from "/assets/app-html.js";
5
- import { t } from "/assets/app-i18n.js";
5
+ import { register, t } from "/assets/app-i18n.js";
6
+ import { recordEdit } from "/assets/text-undo.js";
6
7
  import { appStore, useStore } from "/assets/app-store.js";
7
8
  import { ModsCard } from "./settings-mods.js";
8
- import { EffectsCard } from "./settings-effects.js";
9
+ import { experimentalComponent, useCanvasWorker } from "/assets/feature-flags.js";
10
+ const EffectsCard = experimentalComponent(() => import("./settings-effects.js"), m => m.EffectsCard);
9
11
  import { Icon } from "/assets/ui-icons.js";
10
12
  import { toast } from "/assets/ui-toast.js";
11
13
  import { ownerCssOffHere, ownerCssTrial, safeMode, setOwnerCssActive, setOwnerCssOffHere, startOwnerCssTrial } from "/assets/owner-css.js";
@@ -49,7 +51,26 @@ export function AppearanceSection() {
49
51
  ${group("groupLayout")}<${SidebarWidgetCard} />
50
52
  ${group("groupCss")}<${OwnerCssCard} /><${DeviceCard} />
51
53
  ${group("groupMods")}<${ModsCard} /><${EffectsCard} />
52
- ${group("groupAdvanced")}<${CardLimitsCard} /><${RestoreDefaultsCard} />`;
54
+ ${group("groupAdvanced")}<${ExperimentalEffectsCard} /><${CardLimitsCard} /><${RestoreDefaultsCard} />`;
55
+ }
56
+
57
+ register("experimentalEffects", {
58
+ en: { title: "Experimental: Canvas Worker effects", help: "Enable uploaded Canvas Worker effects. Ambient effects are always available. Saved effects stay intact when this is off.", failed: "Could not change this setting. Try again." },
59
+ "zh-TW": { title: "實驗功能:Canvas Worker 特效", help: "啟用上傳的 Canvas Worker 特效。氛圍特效不受影響;關閉時保留已儲存的特效。", failed: "無法變更此設定,請重試。" },
60
+ });
61
+ function ExperimentalEffectsCard() {
62
+ const enabled = useCanvasWorker(), [writable, setWritable] = useState(false), [busy, setBusy] = useState(false), [failed, setFailed] = useState(false);
63
+ useEffect(() => { let live = true; Promise.resolve(globalThis.AgendAuth?.trialSession?.()).then(value => { if (live) setWritable(value?.localAdmin === true); }, () => {}); return () => { live = false; }; }, []);
64
+ if (!writable) return null;
65
+ const change = async value => {
66
+ setBusy(true); setFailed(false);
67
+ try {
68
+ const result = await confirmedWrite("/api/settings/fleet/web", { method: "PUT", body: { experimental: { canvas_worker: value } }, label: t("experimentalEffects.title") });
69
+ if (result.ok) appStore.set({ canvasWorker: value }); else setFailed(true);
70
+ } catch { setFailed(true); }
71
+ finally { setBusy(false); }
72
+ };
73
+ return html`<section class="card s-appearance s-experimental-effects"><label class="check"><input type="checkbox" checked=${enabled} disabled=${busy} onChange=${event => change(event.target.checked)} /><span>${t("experimentalEffects.title")}</span></label><p class="sub">${t("experimentalEffects.help")}</p>${failed ? html`<p role="alert">${t("experimentalEffects.failed")}</p>` : null}</section>`;
53
74
  }
54
75
 
55
76
  /** #2056 B: a sample sidebar row in the host's default colour places, so the choice is seen rather than described. */
@@ -347,6 +368,7 @@ function SidebarWidgetCard() {
347
368
  const app = useStore(appStore);
348
369
  const saved = sidebarWidgetRaw();
349
370
  const [text, setText] = useState(() => (saved ? JSON.stringify(saved, null, 2) : ""));
371
+ const area = useRef(null);
350
372
  const [error, setError] = useState("");
351
373
  let parsed = null, problem = "";
352
374
  if (text.trim()) {
@@ -359,17 +381,17 @@ function SidebarWidgetCard() {
359
381
  setSidebarWidget(parsed);
360
382
  toast(tn("widgetSaved"));
361
383
  };
362
- const remove = () => { setSidebarWidget(null); setText(""); setError(""); toast(tn("widgetRemoved")); };
384
+ const remove = () => { setSidebarWidget(null); recordEdit(area.current); setText(""); setError(""); toast(tn("widgetRemoved")); };
363
385
  return html`<section class="card s-appearance s-synced s-widget"><${CardTitle} title=${tn("widgetTitle")} topic="widgets" />
364
386
  <p class="sub">${tn("widgetLead")}</p>
365
387
  <label class="field"><span class="sr-only">${tn("widgetTitle")}</span>
366
- <textarea class="s-widget-text" rows="10" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text}
388
+ <textarea ref=${area} class="s-widget-text" rows="10" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text}
367
389
  onInput=${(e) => { setText(e.target.value); setError(""); }}></textarea></label>
368
390
  ${error || problem ? html`<p class="err" role="alert">${error || problem}</p>` : null}
369
391
  ${preview ? html`<div class="s-widget-preview"><${AgentWidget} slot="sidebar" widget=${preview} binds=${fleetBinds(app.instances, app.exec, app.awaiting)} /></div>` : null}
370
392
  <div class="s-widget-actions">
371
393
  <button type="button" class="btn btn-primary" disabled=${!parsed || !!problem} onClick=${save}><${Icon} name="check" size=${16} />${tn("widgetSave")}</button>
372
- ${!text.trim() ? html`<button type="button" class="btn" onClick=${() => setText(JSON.stringify(SIDEBAR_WIDGET_EXAMPLE, null, 2))}>${tn("widgetExample")}</button>` : null}
394
+ ${!text.trim() ? html`<button type="button" class="btn" onClick=${() => { recordEdit(area.current); setText(JSON.stringify(SIDEBAR_WIDGET_EXAMPLE, null, 2)); }}>${tn("widgetExample")}</button>` : null}
373
395
  ${saved ? html`<button type="button" class="btn" onClick=${remove}>${tn("widgetRemove")}</button>` : null}
374
396
  </div>
375
397
  </section>`;
@@ -378,6 +400,7 @@ function SidebarWidgetCard() {
378
400
  function OwnerCssCard() {
379
401
  const [state, setState] = useState(null);
380
402
  const [text, setText] = useState("");
403
+ const area = useRef(null);
381
404
  const [busy, setBusy] = useState(false);
382
405
  const [error, setError] = useState("");
383
406
  const trial = useStore(ownerCssTrial);
@@ -386,6 +409,8 @@ function OwnerCssCard() {
386
409
  const r = await call("/ui/owner-css/state");
387
410
  if (!r.ok || !r.body) { setState({ failed: true }); return; }
388
411
  setState(r.body);
412
+ // #2342: a restored version replaces the editor's text as a step Ctrl+Z goes back from.
413
+ if (opts.undoable) recordEdit(area.current);
389
414
  if (opts.text !== false) setText(r.body.text || "");
390
415
  };
391
416
  useEffect(() => { load(); }, []);
@@ -409,7 +434,7 @@ function OwnerCssCard() {
409
434
  setBusy(false);
410
435
  if (!r.ok) { setError(refusal(r.body)); return; }
411
436
  setOwnerCssActive(r.body.active);
412
- load();
437
+ load({ undoable: true });
413
438
  };
414
439
  const addAsset = async () => {
415
440
  const f = file.current && file.current.files && file.current.files[0];
@@ -440,7 +465,7 @@ function OwnerCssCard() {
440
465
  <p class="sub">${tn("cssLead")}</p>
441
466
  <p class="s-css-status" role="status">${inForce}</p>
442
467
  <label class="field"><span class="sr-only">${tn("cssTitle")}</span>
443
- <textarea class="s-css-text" rows="14" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text} disabled=${busy}
468
+ <textarea ref=${area} class="s-css-text" rows="14" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text} disabled=${busy}
444
469
  onInput=${(e) => setText(e.target.value)}></textarea></label>
445
470
  ${error ? html`<p class="err" role="alert">${error}</p>` : null}
446
471
  <div class="s-css-actions">
@@ -4,6 +4,7 @@
4
4
  // provider key): each goes through confirmedWrite, so a change an admin must confirm waits for it (#1423).
5
5
  import { html, useEffect, useMemo, useRef, useState } from "/assets/app-html.js";
6
6
  import { t } from "/assets/app-i18n.js";
7
+ import { recordEdit } from "/assets/text-undo.js";
7
8
  import { useLease } from "/assets/app-ctx.js";
8
9
  import { Dialog } from "/assets/ui-dialog.js";
9
10
  import { Icon } from "/assets/ui-icons.js";
@@ -16,11 +17,13 @@ import { startOperation } from "./settings-apply.js";
16
17
  import "./settings-strings.js"; // its words, wherever a picker of this file is used (#2072: Fleet's create dialog too)
17
18
  import { backendChoices, modelChoices, OTHER_MODEL, useBackendRows, useModelCatalog } from "./settings-catalog.js";
18
19
  import {
19
- ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
20
+ ACCESS_MODES, AGENT_MODES, CH_TYPES, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
20
21
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
21
- chLabel, hasOwn, impactOf, nonNegative, nonNegativeInteger, positive, replyGuardSupported, sameValue, visibilityDefault,
22
+ chLabel, hasOwn, impactOf, newConnectionImpact, nonNegative, nonNegativeInteger, positive, replyGuardSupported, sameValue, visibilityDefault,
22
23
  classicWebEchoEffective, worstImpact,
23
24
  } from "./settings-model.js";
25
+ /** #2342: a field by id, for recordEdit before a pick or a reset writes it. */
26
+ const byId = (id) => (typeof document !== "undefined" ? document.getElementById(id) : null);
24
27
 
25
28
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
26
29
  export const impactText = (kind) => tn(kind === "now" ? "impactNow" : kind === "fleet" ? "impactFleet" : "impactAgent");
@@ -103,7 +106,7 @@ export function ChipList({ items, onChange, placeholder, label }) {
103
106
 
104
107
  const replyHint = (backend, mode, kiroUi) => (replyGuardSupported(backend, mode, kiroUi) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", backend, mode));
105
108
 
106
- /** The dialog every editor uses: Cancel / Stage change; the footer says what the whole dialog will cost. */
109
+ /** The dialog every editor uses: Cancel / Stage change; the footer says what the changes made so far will cost (#2346: only the fields that changed). */
107
110
  function EditDialog({ title, subtitle, onClose, onStage, impacts, schema, children, ready = true, wide = true }) {
108
111
  const kinds = [...new Set((impacts || []).map((f) => impactOf(schema, f)))];
109
112
  return html`<${Dialog} title=${title} label=${subtitle ? `${title} — ${subtitle}` : title} onClose=${onClose} wide=${wide}
@@ -184,9 +187,9 @@ export function StatusEmojiEditor({ own, platform, channel, previewBody, onChang
184
187
  <label for=${`se-${key}`}>${tn(`se_${key}`)}</label>
185
188
  <input id=${`se-${key}`} type="text" autocomplete="off" value=${values[key] || ""} placeholder=${builtins[key] || ""} onInput=${(e) => set(key, e.target.value)} />
186
189
  <button type="button" class="btn btn-sm" onClick=${() => setPicker(picker === key ? null : key)} aria-expanded=${picker === key ? "true" : "false"}>${tn("se_pick")}</button>
187
- <button type="button" class="icon-btn" aria-label=${tn("se_useDefault")} title=${tn("se_useDefault")} onClick=${() => set(key, "")}><${Icon} name="close" size=${14} /></button>
190
+ <button type="button" class="icon-btn" aria-label=${tn("se_useDefault")} title=${tn("se_useDefault")} onClick=${() => { recordEdit(byId(`se-${key}`)); set(key, ""); }}><${Icon} name="close" size=${14} /></button>
188
191
  ${picker === key ? html`<${EmojiPicker} cat=${cat} keyName=${key} platform=${platform} channel=${channel}
189
- onPick=${(v) => { set(key, v); setPicker(null); }} onClose=${() => setPicker(null)} />` : null}
192
+ onPick=${(v) => { recordEdit(byId(`se-${key}`)); set(key, v); setPicker(null); }} onClose=${() => setPicker(null)} />` : null}
190
193
  </div>`)}</div>
191
194
  <p class="note">${tn("se_hint")}</p>
192
195
  </div>`;
@@ -241,16 +244,17 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
241
244
  };
242
245
  const failoverWarn = !f.inherit.model_failover && !f.model_failover.trim() ? "failoverEmpty" : "";
243
246
  const ready = !errors.auto && !errors.hang;
247
+ const pending = () => agentPatch({ ...f, status_emojis: emojis.value }, inst, defaults, visibilityBaseline, emojis.baseline);
244
248
  const stage = () => {
245
249
  if (!ready) return;
246
- const patch = agentPatch({ ...f, status_emojis: emojis.value }, inst, defaults, visibilityBaseline, emojis.baseline);
250
+ const patch = pending();
247
251
  if (Object.keys(patch).length) ctx.stageAgent(name, inst, patch);
248
252
  onClose();
249
253
  };
250
254
  // What an override shows while it inherits: the default.
251
255
  const shown = (k, own, dflt) => (f.inherit[k] ? dflt : own);
252
256
  const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
253
- return html`<${EditDialog} title=${ctx.shortName(name, inst)} subtitle=${`ID: ${name}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${AGENT_FIELDS} schema=${schema}>
257
+ return html`<${EditDialog} title=${ctx.shortName(name, inst)} subtitle=${`ID: ${name}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${Object.keys(pending()).map((k) => `instance.${k}`)} schema=${schema}>
254
258
  <div class="grid2">
255
259
  <div class="field">${L(tn("displayName"), "instance.display_name", "ag-dn")}
256
260
  <${Override} inherit=${f.inherit.display_name} onInherit=${setInherit("display_name")}>
@@ -406,8 +410,8 @@ export function BotDialog({ id, ctx, onClose }) {
406
410
  setToken(""); setTokenId(null); setReplacing(false);
407
411
  setTokenNote({ text: tn("tokenQueued") });
408
412
  };
409
- const stage = () => {
410
- if (!ch) return;
413
+ /** The connections with this dialog's edits, and which parts changed. */
414
+ const pending = () => {
411
415
  const original = structuredClone(chs);
412
416
  const next = structuredClone(original);
413
417
  next[index] = { ...next[index], access: { ...(next[index].access || {}), mode, allowed_users: [...users] } };
@@ -418,6 +422,16 @@ export function BotDialog({ id, ctx, onClose }) {
418
422
  if (emojis.value) options.status_emojis = emojis.value; else delete options.status_emojis;
419
423
  next[index] = { ...next[index], options };
420
424
  }
425
+ return { next, accessChanged, emojisChanged };
426
+ };
427
+ const pendingImpacts = () => {
428
+ if (!ch) return [];
429
+ const p = pending();
430
+ return [...(p.accessChanged ? ["fleet.channels"] : []), ...(p.emojisChanged ? ["fleet.channel.options.status_emojis"] : [])];
431
+ };
432
+ const stage = () => {
433
+ if (!ch) return;
434
+ const { next, accessChanged, emojisChanged } = pending();
421
435
  if (accessChanged || emojisChanged) {
422
436
  ctx.stageChannels(`connections:${ch.id || chLabel(index)}`, next, {
423
437
  label: tn(accessChanged && emojisChanged ? "updateAccessEmojis" : accessChanged ? "updateAccess" : "updateEmojis", ch.id || chLabel(index)),
@@ -445,7 +459,7 @@ export function BotDialog({ id, ctx, onClose }) {
445
459
  };
446
460
  const L = (text, field, htmlFor) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${htmlFor} />`;
447
461
  if (!ch) return null;
448
- return html`<${EditDialog} title=${type === "telegram" ? "Telegram" : "Discord"} subtitle=${id} onClose=${onClose} onStage=${stage} impacts=${BOT_FIELDS} schema=${schema}>
462
+ return html`<${EditDialog} title=${type === "telegram" ? "Telegram" : "Discord"} subtitle=${id} onClose=${onClose} onStage=${stage} impacts=${pendingImpacts()} schema=${schema}>
449
463
  <div class="grid2">
450
464
  <div class="field">${L(tn("accessMode"), "fleet.channel.access.mode", "bot-mode")}<${Select} id="bot-mode" value=${mode} onChange=${setMode} options=${ACCESS_MODES} />
451
465
  <${Feedback} warning=${accessWarn} /></div>
@@ -503,6 +517,7 @@ export function NewBotDialog({ ctx, onClose }) {
503
517
  // The form's revision (#1529 review): any change to the token, platform, id or group moves it; a Verify or a plan
504
518
  // that began before is dropped when it lands — never restoring an old token, naming it, or planning for it.
505
519
  const rev = useRef(0);
520
+ // The fields keep what was typed (#2342: trimming the box would rewrite it, losing its undo); trimmed here.
506
521
  const body = (x) => ({ platform: x.type, connection_only: true, admin_user_id: x.admin.trim(), ...(x.id.trim() ? { channel_id: x.id.trim() } : {}),
507
522
  ...(x.group.trim() ? (x.type === "discord" ? { guild_id: x.group.trim() } : { group_id: x.group.trim() }) : {}),
508
523
  ...(x.type === "discord" && x.general.trim() ? { general_channel_id: x.general.trim() } : {}) });
@@ -538,7 +553,7 @@ export function NewBotDialog({ ctx, onClose }) {
538
553
  setBusy(true);
539
554
  const p = await planFor(x);
540
555
  if (!p) { if (lease.current()) setBusy(false); return; }
541
- const handed = startOperation([{ label: tn("addBotLabel", p.channel_id), impact: impactOf(ctx.schema, "fleet.channels"),
556
+ const handed = startOperation([{ label: tn("addBotLabel", p.channel_id), impact: newConnectionImpact(ctx.schema),
542
557
  request: { method: "POST", url: "/api/settings/quickstart/commit",
543
558
  body: { ...body(x), channel_id: p.channel_id, token_env: p.token_env, token_env_generated: true, token: x.token }, sensitive: true } }]);
544
559
  if (!handed) { setErr(tn("applyBusyLocal")); setBusy(false); return; }
@@ -560,9 +575,9 @@ export function NewBotDialog({ ctx, onClose }) {
560
575
  invite=${f.identity && f.identity.invite} portal=${f.identity && f.identity.portal} guilds=${f.guilds} onGuilds=${(g) => setF((x) => ({ ...x, guilds: g }))}
561
576
  guild=${f.group} onGuild=${set("group")} channel=${f.general} onChannel=${set("general")} />`
562
577
  : html`<div class="field"><label for="nb-group">${tn("groupIdField")}</label>
563
- <input id="nb-group" type="text" value=${f.group} onInput=${(e) => set("group")(e.target.value.trim())} /></div>`}
578
+ <input id="nb-group" type="text" value=${f.group} onInput=${(e) => set("group")(e.target.value)} /></div>`}
564
579
  <div class="field"><label for="nb-user">${tn("wizardAdminUser")}</label>
565
- <input id="nb-user" type="text" required=${true} inputmode="numeric" pattern=${adminUserIdPattern(f.type)} value=${f.admin} onInput=${(e) => set("admin")(e.target.value.trim())} /></div>
580
+ <input id="nb-user" type="text" required=${true} inputmode="numeric" pattern=${adminUserIdPattern(f.type)} value=${f.admin} onInput=${(e) => set("admin")(e.target.value)} /></div>
566
581
  <${Drawer} title=${tn("advancedSection")}>
567
582
  <div class="field"><label for="nb-id">${tn("connectionIdOptional")}</label><input id="nb-id" type="text" placeholder=${plan ? plan.channel_id : f.type}
568
583
  value=${f.id} onInput=${(e) => set("id")(e.target.value)} onBlur=${() => { if (f.identity && f.identity.valid) planFor(f); }} /></div>
@@ -597,9 +612,7 @@ export function ClassicDialog({ room, ctx, onClose }) {
597
612
  const runtime = ctx.classicRuntime(c.instanceName);
598
613
  const classicMode = fd.agent_mode ?? "mcp";
599
614
  const lease = useLease("classic-dialog");
600
- const stage = async () => {
601
- if (!ready) return;
602
- const patch = changedFields({
615
+ const pending = () => changedFields({
603
616
  backend: f.backend, model: f.model.trim() || null,
604
617
  auto_pause_after: f.inherit.auto_pause_after ? null : Number(f.auto_pause_after),
605
618
  tool_progress: f.inherit.tool_progress ? null : f.tool_progress,
@@ -612,13 +625,16 @@ export function ClassicDialog({ room, ctx, onClose }) {
612
625
  reply_completion_guard: hasOwn(c, "reply_completion_guard") ? c.reply_completion_guard : null,
613
626
  collab: !!c.collab, context_lines: c.context_lines ?? cd.context_lines ?? 5, web_echo: classicWebEchoEffective(c, fleet),
614
627
  });
628
+ const stage = async () => {
629
+ if (!ready) return;
630
+ const patch = pending();
615
631
  if (!Object.keys(patch).length) { onClose(); return; }
616
632
  if (!lease.current()) return;
617
633
  ctx.stageClassic(c, patch);
618
634
  onClose();
619
635
  };
620
636
  const L = (text, field, id) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} />`;
621
- return html`<${EditDialog} title=${c.name || c.instanceName} subtitle=${`ID: ${c.instanceName} · ${tn(`exec_${runtime.execution}`)}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${CLASSIC_FIELDS} schema=${schema}>
637
+ return html`<${EditDialog} title=${c.name || c.instanceName} subtitle=${`ID: ${c.instanceName} · ${tn(`exec_${runtime.execution}`)}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${Object.keys(pending()).map((k) => `classic.${k}`)} schema=${schema}>
622
638
  <div class="grid2">
623
639
  <div class="field">${L(tn("backend"), "classic.backend", "cl-be")}<${BackendSelect} id="cl-be" value=${f.backend} onChange=${set("backend")} /></div>
624
640
  <div class="field">${L(tn("model"), "classic.model", "cl-model")}<${ModelSelect} id="cl-model" value=${f.model} onChange=${set("model")}
@@ -9,6 +9,7 @@
9
9
  // B, nor after A again (an older A), nor once the page is gone.
10
10
  import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
11
  import { t } from "/assets/app-i18n.js";
12
+ import { useTypedText } from "/assets/text-undo.js";
12
13
  import { api } from "./settings-confirm.js";
13
14
  import { Select } from "./settings-dialogs.js";
14
15
 
@@ -23,6 +24,9 @@ const probe = (body) => api("/api/settings/quickstart/probe", { method: "POST",
23
24
  */
24
25
  export function DiscordServerPicker({ idPrefix, token, bot, invite, portal, guilds, onGuilds, guild, onGuild, channel, onChannel }) {
25
26
  const [polling, setPolling] = useState(null); // { timedOut } while waiting for the bot to join
27
+ // #2342: the typed IDs keep what was typed (a space, the undo history); the picks get them trimmed.
28
+ const [guildText, onGuildText] = useTypedText(guild);
29
+ const [channelText, onChannelText] = useTypedText(channel);
26
30
  const [channels, setChannels] = useState(null); // null: not asked; { list } | { error }
27
31
  const live = useRef(true);
28
32
  const now = useRef({ token, guild, channel });
@@ -120,11 +124,11 @@ export function DiscordServerPicker({ idPrefix, token, bot, invite, portal, guil
120
124
  <div class="field"><label for=${`${idPrefix}-guild`}>${tn("guildIdField")}</label>
121
125
  ${guilds && guilds.length
122
126
  ? html`<${Select} id=${`${idPrefix}-guild`} value=${guild} onChange=${(v) => pickGuild(v, true)} options=${["", ...guilds.map((g) => ({ value: g.id, label: `${g.name} (${g.id})` }))]} />`
123
- : html`<input id=${`${idPrefix}-guild`} type="text" value=${guild} placeholder=${tn("discordNoServerYet")} onInput=${(e) => pickGuild(e.target.value.trim(), true)} />`}</div>
127
+ : html`<input id=${`${idPrefix}-guild`} type="text" value=${guildText} placeholder=${tn("discordNoServerYet")} onInput=${(e) => pickGuild(onGuildText(e.target.value), true)} />`}</div>
124
128
  <div class="field"><label for=${`${idPrefix}-gen`}>${tn("wizardGeneralChannel")}</label>
125
129
  ${channels && channels.list && channels.list.length
126
130
  ? html`<${Select} id=${`${idPrefix}-gen`} value=${channel} onChange=${pickChannel} options=${["", ...channels.list.map((c) => ({ value: c.id, label: `#${c.name}` }))]} />`
127
- : html`<input id=${`${idPrefix}-gen`} type="text" value=${channel} onInput=${(e) => pickChannel(e.target.value.trim())} />`}
131
+ : html`<input id=${`${idPrefix}-gen`} type="text" value=${channelText} onInput=${(e) => pickChannel(onChannelText(e.target.value))} />`}
128
132
  ${channels && channels.error ? html`<p class="feedback warning">${channels.error}</p>` : null}</div>
129
133
  </div>`;
130
134
  }
@@ -8,6 +8,7 @@ import { t } from "/assets/app-i18n.js";
8
8
  import { toast } from "/assets/ui-toast.js";
9
9
  import { confirmDialog } from "/assets/ui-confirm.js";
10
10
  import { api } from "./settings-confirm.js";
11
+ import { DEFAULT_SCHEMA, impactOf } from "./settings-model.js";
11
12
  import "./settings-strings.js";
12
13
 
13
14
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
@@ -59,7 +60,7 @@ export function GuestsCard({ ctx, staged, disabled = false }) {
59
60
  const setList = (key, k, list) => setF((x) => { const m = { ...x[key] }; if (list === undefined) delete m[k]; else m[k] = list; return { ...x, [key]: m }; });
60
61
  const review = () => {
61
62
  const body = guestsBody(original, f);
62
- if (body) ctx.stage("guests", { label: tn("guestsTitle"), impact: "now", request: { method: "PUT", url: "/api/settings/fleet/web", body: { guests: body } } });
63
+ if (body) ctx.stage("guests", { label: tn("guestsTitle"), impact: impactOf(ctx.schema || DEFAULT_SCHEMA, "web.guests"), request: { method: "PUT", url: "/api/settings/fleet/web", body: { guests: body } } });
63
64
  else if (staged.has("guests")) ctx.unstage("guests");
64
65
  setMsg(tn("readyApply"));
65
66
  };
@@ -56,6 +56,8 @@ export const chLabel = (i) => (i === 0 ? "primary" : "persona");
56
56
  // ── Impacts: the server's (/api/settings/schema, derived from HOT_INSTANCE_CONFIG_KEYS); this page never decides one ──
57
57
  export const DEFAULT_SCHEMA = { impacts: {}, order: ["now", "instance", "fleet"] };
58
58
  export const impactOf = (schema, field) => (schema.impacts && schema.impacts[field]) || "instance";
59
+ /** #2346: a new connection starts at once while another one runs (the schema says so); the first one needs AgEnD restarted. */
60
+ export const newConnectionImpact = (schema) => (schema && schema.connections_start_live === true ? "now" : impactOf(schema || DEFAULT_SCHEMA, "fleet.channels"));
59
61
  /** A batch costs as much as its worst member, in the order the server ships. */
60
62
  export function batchImpact(schema, keys, impactFor) {
61
63
  const order = schema.order;
@@ -197,17 +199,6 @@ export function agentForm(inst, defaults) {
197
199
  };
198
200
  }
199
201
 
200
- /** Every field the agent dialog shows, for its footer's impact summary. */
201
- export const AGENT_FIELDS = [
202
- "instance.display_name", "instance.description", "instance.backend", "instance.model", "instance.working_directory",
203
- "instance.auto_pause_after", "instance.topic_id", "instance.hang_detector", "instance.agent_mode", "instance.tool_set",
204
- "instance.tool_progress", "instance.cross_instance_visibility", "instance.reply_completion_guard", "instance.log_level",
205
- "instance.lightweight", "instance.model_failover", "instance.systemPrompt", "instance.tags", "instance.general_topic",
206
- "instance.status_emojis",
207
- ];
208
- export const CLASSIC_FIELDS = ["classic.backend", "classic.model", "classic.auto_pause_after", "classic.tool_progress",
209
- "classic.reply_completion_guard", "classic.collab", "classic.context_lines", "classic.web_echo"];
210
- export const BOT_FIELDS = ["fleet.channel.access.mode", "fleet.channel.access.allowed_users", "fleet.channels", "fleet.channel.options.status_emojis"];
211
202
 
212
203
  /** Validation messages (keys of the settings namespace), or "" when the value is fine. */
213
204
  export const nonNegative = (v) => (!Number.isFinite(Number(v)) || String(v).trim() === "" || Number(v) < 0 ? "mustNonNegative" : "");