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

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 (298) 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 +54 -21
  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/grok.js +3 -1
  21. package/dist/backend/grok.js.map +1 -1
  22. package/dist/backend/kiro.d.ts +9 -1
  23. package/dist/backend/kiro.js +102 -1
  24. package/dist/backend/kiro.js.map +1 -1
  25. package/dist/backend/opencode-permission.js +1 -1
  26. package/dist/backend/opencode-permission.js.map +1 -1
  27. package/dist/backend/opencode.js +2 -1
  28. package/dist/backend/opencode.js.map +1 -1
  29. package/dist/binary-probe.js +1 -1
  30. package/dist/bug-report-cli.js +11 -10
  31. package/dist/bug-report-cli.js.map +1 -1
  32. package/dist/bug-report.js +11 -10
  33. package/dist/bug-report.js.map +1 -1
  34. package/dist/bundled-skills.d.ts +7 -3
  35. package/dist/bundled-skills.js +16 -7
  36. package/dist/bundled-skills.js.map +1 -1
  37. package/dist/channel/adapters/discord.js +3 -3
  38. package/dist/channel/adapters/discord.js.map +1 -1
  39. package/dist/channel/mcp-server.js +12 -4
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.d.ts +3 -1
  42. package/dist/channel/mcp-tools.js +36 -0
  43. package/dist/channel/mcp-tools.js.map +1 -1
  44. package/dist/child-process.d.ts +21 -0
  45. package/dist/child-process.js +54 -0
  46. package/dist/child-process.js.map +1 -0
  47. package/dist/cli.js +188 -204
  48. package/dist/cli.js.map +1 -1
  49. package/dist/command-table.d.ts +1 -1
  50. package/dist/command-table.js +1 -1
  51. package/dist/command-table.js.map +1 -1
  52. package/dist/config-validator.js +20 -1
  53. package/dist/config-validator.js.map +1 -1
  54. package/dist/config.js +3 -1
  55. package/dist/config.js.map +1 -1
  56. package/dist/daemon.d.ts +8 -0
  57. package/dist/daemon.js +31 -8
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.js +1 -1
  60. package/dist/doctor.d.ts +5 -0
  61. package/dist/doctor.js +42 -0
  62. package/dist/doctor.js.map +1 -1
  63. package/dist/export-import.js +4 -3
  64. package/dist/export-import.js.map +1 -1
  65. package/dist/fleet-level-config.d.ts +1 -1
  66. package/dist/fleet-level-config.js +2 -0
  67. package/dist/fleet-level-config.js.map +1 -1
  68. package/dist/fleet-manager.d.ts +45 -0
  69. package/dist/fleet-manager.js +324 -36
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/full-restart.d.ts +5 -5
  72. package/dist/full-restart.js +5 -5
  73. package/dist/full-restart.js.map +1 -1
  74. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +48 -10
  75. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  76. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +7 -8
  77. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  78. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
  79. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
  80. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +17 -4
  81. package/dist/general-knowledge/steering/core-rules.md +1 -1
  82. package/dist/instance-config-impact.js +10 -3
  83. package/dist/instance-config-impact.js.map +1 -1
  84. package/dist/instance-lifecycle.js +4 -4
  85. package/dist/locale.js +296 -4
  86. package/dist/locale.js.map +1 -1
  87. package/dist/login-flows.js +1 -1
  88. package/dist/login-shell.js +3 -1
  89. package/dist/login-shell.js.map +1 -1
  90. package/dist/ls-rows.js +1 -1
  91. package/dist/needs-you-hub.d.ts +98 -1
  92. package/dist/needs-you-hub.js +313 -44
  93. package/dist/needs-you-hub.js.map +1 -1
  94. package/dist/needs-you-live.d.ts +5 -2
  95. package/dist/needs-you-live.js +6 -3
  96. package/dist/needs-you-live.js.map +1 -1
  97. package/dist/needs-you.d.ts +58 -3
  98. package/dist/needs-you.js +36 -3
  99. package/dist/needs-you.js.map +1 -1
  100. package/dist/outbound-schemas.d.ts +3 -3
  101. package/dist/public-link-progress.d.ts +12 -1
  102. package/dist/public-link-progress.js +5 -10
  103. package/dist/public-link-progress.js.map +1 -1
  104. package/dist/public-tunnel-config.d.ts +5 -0
  105. package/dist/public-tunnel-config.js +45 -1
  106. package/dist/public-tunnel-config.js.map +1 -1
  107. package/dist/public-web-link.js +6 -5
  108. package/dist/public-web-link.js.map +1 -1
  109. package/dist/quickstart.js +15 -9
  110. package/dist/quickstart.js.map +1 -1
  111. package/dist/resource-report.js +1 -1
  112. package/dist/scheduler/task-board.d.ts +41 -1
  113. package/dist/scheduler/task-board.js +152 -5
  114. package/dist/scheduler/task-board.js.map +1 -1
  115. package/dist/sd-notify.js +2 -1
  116. package/dist/sd-notify.js.map +1 -1
  117. package/dist/service-installer.d.ts +69 -0
  118. package/dist/service-installer.js +182 -0
  119. package/dist/service-installer.js.map +1 -1
  120. package/dist/service-owner.d.ts +44 -0
  121. package/dist/service-owner.js +48 -0
  122. package/dist/service-owner.js.map +1 -0
  123. package/dist/service-plan.d.ts +8 -0
  124. package/dist/service-plan.js +13 -0
  125. package/dist/service-plan.js.map +1 -1
  126. package/dist/settings-api.d.ts +4 -0
  127. package/dist/settings-api.js +21 -3
  128. package/dist/settings-api.js.map +1 -1
  129. package/dist/settings-change.js +88 -4
  130. package/dist/settings-change.js.map +1 -1
  131. package/dist/settings-confirmation.d.ts +2 -0
  132. package/dist/settings-confirmation.js +14 -8
  133. package/dist/settings-confirmation.js.map +1 -1
  134. package/dist/settings-control.js +2 -1
  135. package/dist/settings-control.js.map +1 -1
  136. package/dist/settings-effect.js +2 -0
  137. package/dist/settings-effect.js.map +1 -1
  138. package/dist/settings-http-confirmation.d.ts +2 -0
  139. package/dist/settings-http-confirmation.js +3 -2
  140. package/dist/settings-http-confirmation.js.map +1 -1
  141. package/dist/settings-request-capability.d.ts +4 -1
  142. package/dist/settings-request-capability.js +11 -3
  143. package/dist/settings-request-capability.js.map +1 -1
  144. package/dist/setup-auth.d.ts +1 -0
  145. package/dist/setup-auth.js +2 -1
  146. package/dist/setup-auth.js.map +1 -1
  147. package/dist/setup-host.d.ts +1 -0
  148. package/dist/setup-host.js +7 -3
  149. package/dist/setup-host.js.map +1 -1
  150. package/dist/setup-wizard.js +1 -1
  151. package/dist/setup-wizard.js.map +1 -1
  152. package/dist/task-board-summary.d.ts +101 -0
  153. package/dist/task-board-summary.js +177 -0
  154. package/dist/task-board-summary.js.map +1 -0
  155. package/dist/task-board-waker.d.ts +8 -1
  156. package/dist/task-board-waker.js +15 -6
  157. package/dist/task-board-waker.js.map +1 -1
  158. package/dist/tmux-control.js +1 -1
  159. package/dist/tmux-control.js.map +1 -1
  160. package/dist/tmux-exec-helper.d.ts +13 -3
  161. package/dist/tmux-exec-helper.js +34 -29
  162. package/dist/tmux-exec-helper.js.map +1 -1
  163. package/dist/tmux-manager.js +1 -1
  164. package/dist/tmux-read.js +1 -1
  165. package/dist/tmux-read.js.map +1 -1
  166. package/dist/topic-commands.d.ts +3 -0
  167. package/dist/topic-commands.js +29 -18
  168. package/dist/topic-commands.js.map +1 -1
  169. package/dist/transcript-monitor.js +5 -0
  170. package/dist/transcript-monitor.js.map +1 -1
  171. package/dist/transcript-sources.d.ts +2 -0
  172. package/dist/transcript-sources.js +4 -0
  173. package/dist/transcript-sources.js.map +1 -1
  174. package/dist/tunnel/cloudflared-install.js +3 -1
  175. package/dist/tunnel/cloudflared-install.js.map +1 -1
  176. package/dist/tunnel/cloudflared.d.ts +1 -1
  177. package/dist/tunnel/cloudflared.js +51 -10
  178. package/dist/tunnel/cloudflared.js.map +1 -1
  179. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  180. package/dist/tunnel/named-diagnostics.js +52 -0
  181. package/dist/tunnel/named-diagnostics.js.map +1 -0
  182. package/dist/tunnel/types.d.ts +2 -1
  183. package/dist/tunnel/types.js.map +1 -1
  184. package/dist/types.d.ts +9 -0
  185. package/dist/ui/app-effects.js +10 -4
  186. package/dist/ui/chat-strings.js +3 -0
  187. package/dist/ui/chat-thread.js +14 -7
  188. package/dist/ui/effect-trial-offer.js +2 -31
  189. package/dist/ui/effects-waker.js +2 -70
  190. package/dist/ui/fleet-task-card.js +5 -5
  191. package/dist/ui/fleet-task-common.js +43 -9
  192. package/dist/ui/fleet-task-work.js +107 -0
  193. package/dist/ui/fleet-tasks.js +46 -12
  194. package/dist/ui/image-lightbox.js +39 -1
  195. package/dist/ui/image-zoom.js +242 -0
  196. package/dist/ui/panel-chat.js +24 -13
  197. package/dist/ui/panel-details.js +2 -0
  198. package/dist/ui/panel-fleet.js +6 -2
  199. package/dist/ui/panel-needs.js +21 -6
  200. package/dist/ui/panel-settings.js +35 -13
  201. package/dist/ui/preview-panel.js +2 -1
  202. package/dist/ui/search-dialog.js +2 -1
  203. package/dist/ui/settings-appearance.js +44 -10
  204. package/dist/ui/settings-dialogs.js +33 -17
  205. package/dist/ui/settings-discord.js +6 -2
  206. package/dist/ui/settings-guests.js +2 -1
  207. package/dist/ui/settings-model.js +2 -11
  208. package/dist/ui/settings-mods.js +8 -3
  209. package/dist/ui/settings-strings.js +32 -0
  210. package/dist/ui/settings-token.js +3 -1
  211. package/dist/ui/settings-web-authority.js +6 -3
  212. package/dist/ui/settings-wizard.js +13 -7
  213. package/dist/ui/shared/app-i18n.js +2 -2
  214. package/dist/ui/shared/app-needs.js +2 -2
  215. package/dist/ui/shared/app-store.js +2 -2
  216. package/dist/ui/shared/app.css +93 -32
  217. package/dist/ui/shared/app.js +5 -1
  218. package/dist/ui/shared/effect-app-runtime.js +7 -5
  219. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  220. package/dist/ui/shared/effect-cursor.js +3 -2
  221. package/dist/ui/shared/effect-display-metadata.js +39 -0
  222. package/dist/ui/shared/effect-registry.js +4 -2
  223. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  224. package/dist/ui/shared/effect-stock-trust.js +5 -5
  225. package/dist/ui/shared/feature-flags.js +127 -0
  226. package/dist/ui/shared/identity-color.js +6 -3
  227. package/dist/ui/shared/instance-nav.js +3 -2
  228. package/dist/ui/shared/panel-view.js +14 -29
  229. package/dist/ui/shared/text-undo.js +210 -0
  230. package/dist/ui/shared/ui-dialog.js +4 -3
  231. package/dist/ui/shared/ui-icons.js +2 -0
  232. package/dist/ui/shared/usage-dialog.js +29 -8
  233. package/dist/ui/shared/view-strings.js +2 -4
  234. package/dist/ui/web-terminal/terminal.html +10 -10
  235. package/dist/ui/web-terminal/terminal.js +58 -12
  236. package/dist/update-dispatch.js +1 -1
  237. package/dist/update-launch.d.ts +1 -1
  238. package/dist/update-launch.js +1 -2
  239. package/dist/update-launch.js.map +1 -1
  240. package/dist/update-preflight.js +1 -1
  241. package/dist/usage/providers.js +1 -1
  242. package/dist/usage/providers.js.map +1 -1
  243. package/dist/view-api.js +1 -1
  244. package/dist/web-api.d.ts +6 -0
  245. package/dist/web-api.js +163 -21
  246. package/dist/web-api.js.map +1 -1
  247. package/dist/web-app-icon-worker.js +2 -1
  248. package/dist/web-app-icon-worker.js.map +1 -1
  249. package/dist/web-app-icon.d.ts +10 -8
  250. package/dist/web-app-icon.js +14 -13
  251. package/dist/web-app-icon.js.map +1 -1
  252. package/dist/web-appearance-tools.d.ts +1 -0
  253. package/dist/web-appearance-tools.js +7 -3
  254. package/dist/web-appearance-tools.js.map +1 -1
  255. package/dist/web-appearance.d.ts +4 -10
  256. package/dist/web-appearance.js +4 -3
  257. package/dist/web-appearance.js.map +1 -1
  258. package/dist/web-auth.d.ts +8 -4
  259. package/dist/web-auth.js +28 -17
  260. package/dist/web-auth.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +14 -0
  262. package/dist/web-chat-history.js +26 -1
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-cookie-names.d.ts +5 -0
  265. package/dist/web-cookie-names.js +12 -0
  266. package/dist/web-cookie-names.js.map +1 -0
  267. package/dist/web-experimental-http.d.ts +2 -0
  268. package/dist/web-experimental-http.js +12 -0
  269. package/dist/web-experimental-http.js.map +1 -0
  270. package/dist/web-experimental.d.ts +11 -0
  271. package/dist/web-experimental.js +49 -0
  272. package/dist/web-experimental.js.map +1 -0
  273. package/dist/web-guest-access.d.ts +1 -0
  274. package/dist/web-guest-access.js +4 -2
  275. package/dist/web-guest-access.js.map +1 -1
  276. package/dist/web-mod-store.d.ts +2 -0
  277. package/dist/web-mod-store.js +10 -0
  278. package/dist/web-mod-store.js.map +1 -1
  279. package/dist/web-png.d.ts +4 -0
  280. package/dist/web-png.js +10 -2
  281. package/dist/web-png.js.map +1 -1
  282. package/dist/web-preview.d.ts +2 -0
  283. package/dist/web-preview.js +2 -0
  284. package/dist/web-preview.js.map +1 -1
  285. package/dist/web-returning-device.d.ts +4 -2
  286. package/dist/web-returning-device.js +12 -5
  287. package/dist/web-returning-device.js.map +1 -1
  288. package/dist/web-settings-patch.d.ts +2 -1
  289. package/dist/web-settings-patch.js +5 -1
  290. package/dist/web-settings-patch.js.map +1 -1
  291. package/dist/web-settings-tools.d.ts +1 -0
  292. package/dist/web-settings-tools.js +14 -10
  293. package/dist/web-settings-tools.js.map +1 -1
  294. package/dist/web-terminal.js +1 -1
  295. package/dist/web-theme-store.d.ts +2 -2
  296. package/dist/web-theme-store.js +6 -3
  297. package/dist/web-theme-store.js.map +1 -1
  298. package/package.json +11 -11
@@ -21,6 +21,7 @@ import { Menu } from "/assets/ui-menu.js";
21
21
  import { Dialog } from "/assets/ui-dialog.js";
22
22
  import { Empty, Skeleton } from "/assets/ui-states.js";
23
23
  import { Icon } from "/assets/ui-icons.js";
24
+ import { recordEdit } from "/assets/text-undo.js";
24
25
  import { toast } from "/assets/ui-toast.js";
25
26
  import { confirmDialog } from "/assets/ui-confirm.js";
26
27
  import { savePref } from "/assets/app-prefs.js";
@@ -74,11 +75,14 @@ import { PreviewPanel, claimPanel, openPanel, panelClaims, panelStore, panelVers
74
75
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
75
76
  import { installTour, refreshTourSpot } from "./chat-tour.js";
76
77
  import { LightboxHost, openLightbox } from "./image-lightbox.js";
77
- import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
78
- import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
78
+ import { canvasWorkerMenuAvailable, experimentalComponent, useCanvasWorker } from "/assets/feature-flags.js";
79
+ const ChatEffectPosters = experimentalComponent(() => import("./chat-effect-posters.js"), m => m.ChatEffectPosters);
80
+ const ChatEffects = experimentalComponent(() => import("./chat-effects.js"), m => m.ChatEffects);
79
81
  import { setFollowsReducedMotion } from "/assets/fx-motion.js";
80
82
  import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
81
- import { startEffectsWaker, getAppEffects, wakeEffects, effectsLoaded, onEffectsAwake, chatHasEffects } from "./effects-waker.js";
83
+ import { startEffectsWaker, getAppEffects, wakeEffects, effectsLoaded, onEffectsAwake, chatHasEffects } from "/assets/feature-flags.js";
84
+ /** The composer's textarea (#2342: text put back into it is a step Ctrl+Z goes back from). */
85
+ const composerField = () => (typeof document !== "undefined" ? document.getElementById("msgIn") : null);
82
86
 
83
87
  const R = () => globalThis.AgendChatRender;
84
88
  const P = () => globalThis.AgendPreview;
@@ -297,7 +301,8 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
297
301
  else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
298
302
  };
299
303
  // #2193: this chat's Canvas Worker placements (its status row's effect_chat_v): none, no effects code runs for it.
300
- const fxHere = chatHasEffects(name);
304
+ const cwEnabled = useCanvasWorker();
305
+ const fxHere = cwEnabled && chatHasEffects(name);
301
306
  const items = [
302
307
  // #1523 N2: Details is a page now (the Fleet side of this instance), not a dialog.
303
308
  { key: "details", label: t("chat.details"), icon: "info", onSelect: () => navigate(detailsPath(name)) },
@@ -310,7 +315,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
310
315
  ...themeMenuItems(name),
311
316
  // #2117: its ambient — why it is not showing here (with the way to change that), and a person's Clear.
312
317
  ...ambientMenuItems(name, { publicLink: document.body.dataset.publicLink === "1", clear: clearAmbient }),
313
- chatEffectsMenuAvailable() ? { key: "effects", label: t("chatFx.title"), onSelect: () => setDialog("effects") } : null,
318
+ cwEnabled && canvasWorkerMenuAvailable(window) ? { key: "effects", label: t("cwControls.title"), onSelect: () => setDialog("effects") } : null,
314
319
  { key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
315
320
  ];
316
321
  const cls = statusClass(inst, exec, awaiting);
@@ -451,11 +456,16 @@ function follow(name, thread) {
451
456
  }));
452
457
  offs.push(P() ? P().onChange(() => thread.refreshCards()) : () => {});
453
458
  // #2193: the runtime may start later (an offer shown wakes it): its trial updates are followed from then.
454
- let trialOff = null;
455
- const watchTrial = () => { trialOff = getAppEffects()?.subscribeTrial(() => thread.render(store.state.msgs[name] || [])) ?? null; };
459
+ let trialOff = null, trialOwner = null;
460
+ const watchTrial = () => {
461
+ const next = getAppEffects();
462
+ if (next === trialOwner) return;
463
+ trialOff?.(); trialOff = null; trialOwner = next;
464
+ trialOff = next?.subscribeTrial(() => thread.render(store.state.msgs[name] || [])) ?? null;
465
+ };
456
466
  watchTrial();
457
- if (!trialOff) offs.push(onEffectsAwake(() => { watchTrial(); thread.render(store.state.msgs[name] || []); }));
458
- offs.push(() => trialOff?.());
467
+ offs.push(onEffectsAwake(() => { watchTrial(); thread.render(store.state.msgs[name] || []); }));
468
+ offs.push(() => { trialOff?.(); trialOff = null; trialOwner = null; });
459
469
  offs.push(panelStore.subscribe(() => thread.refreshCards())); // a card shows whether the panel has its block
460
470
  return () => { for (const off of offs) off(); };
461
471
  }
@@ -537,7 +547,7 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
537
547
  <${CommandCard} name=${name} lease=${lease} />
538
548
  <${QuickActions} name=${name} inst=${inst} lease=${lease} />
539
549
  ${s.failedSends[name] ? html`<div class="failed-send" role="alert"><span>${t("chat.notSent")}</span><span class="txt">${s.failedSends[name]}</span>
540
- <button type="button" class="btn btn-primary btn-sm" onClick=${() => store.putBack(name)}>${t("chat.putBack")}</button>
550
+ <button type="button" class="btn btn-primary btn-sm" onClick=${() => { recordEdit(composerField()); store.putBack(name); }}>${t("chat.putBack")}</button>
541
551
  <button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
542
552
  ${s.waking[name] ? html`<p class="send-waking" role="status">${t("chat.waking", name)}</p>` : null}
543
553
  ${s.sendFailures[name] ? html`<div class="failed-send send-failure" role="alert"><span>${s.sendFailures[name].error}</span>
@@ -654,7 +664,8 @@ function Composer({ name, lease, busy }) {
654
664
  setValue("");
655
665
  store.runCommand(name, cmd.command, cmd.args, { alive: () => lease.current() });
656
666
  };
657
- const complete = (c) => setValue(`/${c.name}${c.arg ? " " : ""}`);
667
+ // #2342: a completion is a step Ctrl+Z goes back from (to what was typed); a command that runs consumes the line.
668
+ const complete = (c) => { recordEdit(input.current); setValue(`/${c.name}${c.arg ? " " : ""}`); };
658
669
  const submit = () => {
659
670
  if (pick) {
660
671
  if (value === `/${pick.name}` && !needsArgument(pick.name)) runLine({ command: pick.name, args: "" });
@@ -664,7 +675,7 @@ function Composer({ name, lease, busy }) {
664
675
  // #1920: a guest's "/…" would be refused as a command — said here, the draft kept to edit.
665
676
  if (guest && /^\s*\//.test(value)) { toast(t("chat.guestNoCommands"), false); return; }
666
677
  const cmd = files.length ? null : parseCommandLine(value);
667
- if (cmd && needsArgument(cmd.command) && !cmd.args) { setValue(`/${cmd.command} `); return; }
678
+ if (cmd && needsArgument(cmd.command) && !cmd.args) { recordEdit(input.current); setValue(`/${cmd.command} `); return; }
668
679
  if (cmd) { runLine(cmd); return; }
669
680
  store.send(name);
670
681
  };
@@ -728,7 +739,7 @@ function FileChip({ f, i, name }) {
728
739
  return html`<span class="file-chip">
729
740
  ${isImage ? html`<img ref=${img} alt="" />` : html`<span class="ic"><${Icon} name="file" size=${16} /></span>`}
730
741
  <span class="nm">${f.name}</span><span class="sz">${R().formatSize(f.size)}</span>
731
- ${store.isPasted(f) ? html`<button type="button" class="as-text" onClick=${() => store.fileBackAsText(name, i)}>${t("chat.asText")}</button>` : null}
742
+ ${store.isPasted(f) ? html`<button type="button" class="as-text" onClick=${() => { recordEdit(composerField()); store.fileBackAsText(name, i); }}>${t("chat.asText")}</button>` : null}
732
743
  <button type="button" class="chip-x" title=${t("chat.removeFile")} aria-label=${`${t("chat.removeFile")} ${f.name}`} onClick=${() => store.removeFile(name, i)}><${Icon} name="close" size=${14} /></button></span>`;
733
744
  }
734
745
 
@@ -25,6 +25,7 @@ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
25
25
  import { toast } from "/assets/ui-toast.js";
26
26
  import "./chat-strings.js";
27
27
  import { api } from "./settings-confirm.js";
28
+ import { InstanceBoardWork } from "./fleet-task-work.js";
28
29
  import { requestAgentSettings } from "./settings-request.js";
29
30
 
30
31
  export function DetailsPanel({ route, navKey }) {
@@ -150,6 +151,7 @@ function DetailsView({ name, inst, navKey, exec, awaiting }) {
150
151
  ${row(t("chat.dContext"), html`<span class="pct">${ctx != null ? `${Math.round(ctx)}%` : "--"}</span>${ctx != null ? bar(ctx, ctx > 80 ? "warn" : "") : null}`)}
151
152
  ${row(t("chat.dRate5"), html`<span class="pct">${Math.round(rl5)}%</span>${bar(rl5, rl5 > 90 ? "error" : rl5 > 70 ? "warn" : "")}`)}
152
153
  ${row(t("chat.dRate7"), html`<span class="pct">${Math.round(rl7)}%</span>${bar(rl7, rl7 > 90 ? "error" : rl7 > 70 ? "warn" : "")}`)}</section>
154
+ <${InstanceBoardWork} name=${name} onOpen=${(id) => navigate(`/ui/fleet?card=${encodeURIComponent(id)}`)} />
153
155
  <section class="card"><h3>${t("chat.dActivity")}</h3>${d.recent_activity?.length ? d.recent_activity.map((a, i) => html`<div key=${i} class="activity">
154
156
  <span class="mono muted">${a.timestamp ? new Date(`${a.timestamp.replace(" ", "T")}Z`).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}</span>
155
157
  <span class="ev">${a.event}</span> ${a.summary || ""}</div>`) : html`<p class="note">${t("chat.dNoActivity")}</p>`}</section>`;
@@ -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";
@@ -21,6 +23,15 @@ import { CardTitle } from "./settings-learn.js";
21
23
  import "./settings-strings.js";
22
24
 
23
25
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
26
+ /** #2390: a refusal's detail in the page's language when the host named it (detail_key), else its English as sent. */
27
+ export function appIconDetail(body) {
28
+ const key = body && typeof body.detail_key === "string" ? `appIconDetail_${body.detail_key}` : null;
29
+ const english = (body && body.detail) || "";
30
+ if (!key) return english;
31
+ const args = Array.isArray(body.detail_args) ? body.detail_args : [];
32
+ const said = tn(key, ...(body.detail_key === "corrupt" ? [english] : args));
33
+ return said === `settings.${key}` ? english : said;
34
+ }
24
35
  const short = (sha) => (sha ? sha.slice(0, 8) : "");
25
36
  const kib = (n) => `${Math.max(1, Math.round(n / 1024))} KiB`;
26
37
 
@@ -49,7 +60,26 @@ export function AppearanceSection() {
49
60
  ${group("groupLayout")}<${SidebarWidgetCard} />
50
61
  ${group("groupCss")}<${OwnerCssCard} /><${DeviceCard} />
51
62
  ${group("groupMods")}<${ModsCard} /><${EffectsCard} />
52
- ${group("groupAdvanced")}<${CardLimitsCard} /><${RestoreDefaultsCard} />`;
63
+ ${group("groupAdvanced")}<${ExperimentalEffectsCard} /><${CardLimitsCard} /><${RestoreDefaultsCard} />`;
64
+ }
65
+
66
+ register("experimentalEffects", {
67
+ 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." },
68
+ "zh-TW": { title: "實驗功能:Canvas Worker 特效", help: "啟用上傳的 Canvas Worker 特效。氛圍特效不受影響;關閉時保留已儲存的特效。", failed: "無法變更此設定,請重試。" },
69
+ });
70
+ function ExperimentalEffectsCard() {
71
+ const enabled = useCanvasWorker(), [writable, setWritable] = useState(false), [busy, setBusy] = useState(false), [failed, setFailed] = useState(false);
72
+ useEffect(() => { let live = true; Promise.resolve(globalThis.AgendAuth?.trialSession?.()).then(value => { if (live) setWritable(value?.localAdmin === true); }, () => {}); return () => { live = false; }; }, []);
73
+ if (!writable) return null;
74
+ const change = async value => {
75
+ setBusy(true); setFailed(false);
76
+ try {
77
+ const result = await confirmedWrite("/api/settings/fleet/web", { method: "PUT", body: { experimental: { canvas_worker: value } }, label: t("experimentalEffects.title") });
78
+ if (result.ok) appStore.set({ canvasWorker: value }); else setFailed(true);
79
+ } catch { setFailed(true); }
80
+ finally { setBusy(false); }
81
+ };
82
+ 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
83
  }
54
84
 
55
85
  /** #2056 B: a sample sidebar row in the host's default colour places, so the choice is seen rather than described. */
@@ -210,8 +240,8 @@ function AppIconCard() {
210
240
  const answered = (r, done) => {
211
241
  setBusy(false);
212
242
  if (!r.ok) {
213
- const known = ["app_icon_too_large", "app_icon_not_png", "app_icon_unreadable", "app_icon_admin_only", "app_icon_needs_a_signed_in_person", "app_icon_busy", "app_icon_superseded", "app_icon_not_reset"];
214
- setError(known.includes(r.body && r.body.error) ? tn(`appIconError_${r.body.error}`, (r.body && r.body.detail) || "") : tn("appIconError_other", (r.body && r.body.error) || String(r.status)));
243
+ const known = ["app_icon_too_large", "app_icon_not_png", "app_icon_unreadable", "app_icon_admin_only", "app_icon_needs_a_signed_in_person", "app_icon_busy", "app_icon_superseded", "app_icon_not_reset", "app_icon_not_kept", "app_icon_failed"];
244
+ setError(known.includes(r.body && r.body.error) ? tn(`appIconError_${r.body.error}`, appIconDetail(r.body)) : tn("appIconError_other", (r.body && r.body.error) || String(r.status)));
215
245
  return;
216
246
  }
217
247
  setState(r.body);
@@ -347,6 +377,7 @@ function SidebarWidgetCard() {
347
377
  const app = useStore(appStore);
348
378
  const saved = sidebarWidgetRaw();
349
379
  const [text, setText] = useState(() => (saved ? JSON.stringify(saved, null, 2) : ""));
380
+ const area = useRef(null);
350
381
  const [error, setError] = useState("");
351
382
  let parsed = null, problem = "";
352
383
  if (text.trim()) {
@@ -359,17 +390,17 @@ function SidebarWidgetCard() {
359
390
  setSidebarWidget(parsed);
360
391
  toast(tn("widgetSaved"));
361
392
  };
362
- const remove = () => { setSidebarWidget(null); setText(""); setError(""); toast(tn("widgetRemoved")); };
393
+ const remove = () => { setSidebarWidget(null); recordEdit(area.current); setText(""); setError(""); toast(tn("widgetRemoved")); };
363
394
  return html`<section class="card s-appearance s-synced s-widget"><${CardTitle} title=${tn("widgetTitle")} topic="widgets" />
364
395
  <p class="sub">${tn("widgetLead")}</p>
365
396
  <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}
397
+ <textarea ref=${area} class="s-widget-text" rows="10" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text}
367
398
  onInput=${(e) => { setText(e.target.value); setError(""); }}></textarea></label>
368
399
  ${error || problem ? html`<p class="err" role="alert">${error || problem}</p>` : null}
369
400
  ${preview ? html`<div class="s-widget-preview"><${AgentWidget} slot="sidebar" widget=${preview} binds=${fleetBinds(app.instances, app.exec, app.awaiting)} /></div>` : null}
370
401
  <div class="s-widget-actions">
371
402
  <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}
403
+ ${!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
404
  ${saved ? html`<button type="button" class="btn" onClick=${remove}>${tn("widgetRemove")}</button>` : null}
374
405
  </div>
375
406
  </section>`;
@@ -378,6 +409,7 @@ function SidebarWidgetCard() {
378
409
  function OwnerCssCard() {
379
410
  const [state, setState] = useState(null);
380
411
  const [text, setText] = useState("");
412
+ const area = useRef(null);
381
413
  const [busy, setBusy] = useState(false);
382
414
  const [error, setError] = useState("");
383
415
  const trial = useStore(ownerCssTrial);
@@ -386,6 +418,8 @@ function OwnerCssCard() {
386
418
  const r = await call("/ui/owner-css/state");
387
419
  if (!r.ok || !r.body) { setState({ failed: true }); return; }
388
420
  setState(r.body);
421
+ // #2342: a restored version replaces the editor's text as a step Ctrl+Z goes back from.
422
+ if (opts.undoable) recordEdit(area.current);
389
423
  if (opts.text !== false) setText(r.body.text || "");
390
424
  };
391
425
  useEffect(() => { load(); }, []);
@@ -409,7 +443,7 @@ function OwnerCssCard() {
409
443
  setBusy(false);
410
444
  if (!r.ok) { setError(refusal(r.body)); return; }
411
445
  setOwnerCssActive(r.body.active);
412
- load();
446
+ load({ undoable: true });
413
447
  };
414
448
  const addAsset = async () => {
415
449
  const f = file.current && file.current.files && file.current.files[0];
@@ -440,7 +474,7 @@ function OwnerCssCard() {
440
474
  <p class="sub">${tn("cssLead")}</p>
441
475
  <p class="s-css-status" role="status">${inForce}</p>
442
476
  <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}
477
+ <textarea ref=${area} class="s-css-text" rows="14" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text} disabled=${busy}
444
478
  onInput=${(e) => setText(e.target.value)}></textarea></label>
445
479
  ${error ? html`<p class="err" role="alert">${error}</p>` : null}
446
480
  <div class="s-css-actions">