@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.4

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 (257) hide show
  1. package/README.md +4 -4
  2. package/README.zh-TW.md +4 -4
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +14 -2
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/atomic-write.d.ts +21 -0
  7. package/dist/atomic-write.js +65 -0
  8. package/dist/atomic-write.js.map +1 -0
  9. package/dist/auth-api.js +2 -0
  10. package/dist/auth-api.js.map +1 -1
  11. package/dist/backend/codex.d.ts +7 -0
  12. package/dist/backend/codex.js +7 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/muse.d.ts +7 -0
  15. package/dist/backend/muse.js +7 -0
  16. package/dist/backend/muse.js.map +1 -1
  17. package/dist/backend/types.d.ts +6 -0
  18. package/dist/backend/types.js.map +1 -1
  19. package/dist/binary-probe.d.ts +80 -0
  20. package/dist/binary-probe.js +182 -0
  21. package/dist/binary-probe.js.map +1 -0
  22. package/dist/cache-service.d.ts +17 -1
  23. package/dist/cache-service.js +39 -4
  24. package/dist/cache-service.js.map +1 -1
  25. package/dist/channel/adapters/discord.d.ts +5 -1
  26. package/dist/channel/adapters/discord.js +56 -11
  27. package/dist/channel/adapters/discord.js.map +1 -1
  28. package/dist/channel/adapters/telegram.d.ts +38 -1
  29. package/dist/channel/adapters/telegram.js +82 -0
  30. package/dist/channel/adapters/telegram.js.map +1 -1
  31. package/dist/channel/mcp-tools.js +1 -1
  32. package/dist/channel/mcp-tools.js.map +1 -1
  33. package/dist/channel/tool-router.d.ts +8 -1
  34. package/dist/channel/tool-router.js +4 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +21 -0
  37. package/dist/cli.js +133 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/connection-secrets.d.ts +5 -0
  40. package/dist/connection-secrets.js.map +1 -1
  41. package/dist/cpu-profile.js +50 -0
  42. package/dist/cpu-profile.js.map +1 -1
  43. package/dist/daemon.d.ts +12 -0
  44. package/dist/daemon.js +166 -26
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/delivery-outbox.d.ts +1 -0
  47. package/dist/delivery-outbox.js +12 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/discord-general.d.ts +15 -0
  50. package/dist/discord-general.js +24 -0
  51. package/dist/discord-general.js.map +1 -0
  52. package/dist/discord-permissions.d.ts +33 -0
  53. package/dist/discord-permissions.js +42 -0
  54. package/dist/discord-permissions.js.map +1 -0
  55. package/dist/env-file.d.ts +11 -0
  56. package/dist/env-file.js +27 -0
  57. package/dist/env-file.js.map +1 -0
  58. package/dist/fleet-context.d.ts +5 -0
  59. package/dist/fleet-context.js.map +1 -1
  60. package/dist/fleet-level-config.d.ts +8 -1
  61. package/dist/fleet-level-config.js +31 -0
  62. package/dist/fleet-level-config.js.map +1 -1
  63. package/dist/fleet-manager.d.ts +76 -7
  64. package/dist/fleet-manager.js +610 -142
  65. package/dist/fleet-manager.js.map +1 -1
  66. package/dist/fleet-stop-budget.d.ts +22 -0
  67. package/dist/fleet-stop-budget.js +95 -0
  68. package/dist/fleet-stop-budget.js.map +1 -0
  69. package/dist/kiro-transcript-lane.d.ts +8 -4
  70. package/dist/kiro-transcript-lane.js +66 -44
  71. package/dist/kiro-transcript-lane.js.map +1 -1
  72. package/dist/locale.js +88 -0
  73. package/dist/locale.js.map +1 -1
  74. package/dist/next-steps.d.ts +5 -0
  75. package/dist/next-steps.js +26 -0
  76. package/dist/next-steps.js.map +1 -0
  77. package/dist/outbound-handlers.js +8 -0
  78. package/dist/outbound-handlers.js.map +1 -1
  79. package/dist/outbound-schemas.d.ts +4 -0
  80. package/dist/outbound-schemas.js +6 -1
  81. package/dist/outbound-schemas.js.map +1 -1
  82. package/dist/outbox-open-error.d.ts +7 -0
  83. package/dist/outbox-open-error.js +15 -0
  84. package/dist/outbox-open-error.js.map +1 -0
  85. package/dist/pane-context-cache.d.ts +25 -0
  86. package/dist/pane-context-cache.js +73 -0
  87. package/dist/pane-context-cache.js.map +1 -0
  88. package/dist/paths.d.ts +0 -7
  89. package/dist/paths.js +8 -2
  90. package/dist/paths.js.map +1 -1
  91. package/dist/provider-probe.d.ts +15 -0
  92. package/dist/provider-probe.js +20 -0
  93. package/dist/provider-probe.js.map +1 -1
  94. package/dist/public-link-progress.d.ts +75 -0
  95. package/dist/public-link-progress.js +204 -0
  96. package/dist/public-link-progress.js.map +1 -0
  97. package/dist/public-web-gateway.js +4 -1
  98. package/dist/public-web-gateway.js.map +1 -1
  99. package/dist/public-web-link.d.ts +9 -2
  100. package/dist/public-web-link.js +89 -15
  101. package/dist/public-web-link.js.map +1 -1
  102. package/dist/quickstart-api.d.ts +93 -20
  103. package/dist/quickstart-api.js +198 -63
  104. package/dist/quickstart-api.js.map +1 -1
  105. package/dist/quickstart.d.ts +15 -0
  106. package/dist/quickstart.js +100 -29
  107. package/dist/quickstart.js.map +1 -1
  108. package/dist/reply-buttons-controller.d.ts +98 -0
  109. package/dist/reply-buttons-controller.js +174 -0
  110. package/dist/reply-buttons-controller.js.map +1 -0
  111. package/dist/reply-buttons.d.ts +113 -0
  112. package/dist/reply-buttons.js +208 -0
  113. package/dist/reply-buttons.js.map +1 -0
  114. package/dist/reply-dedup.js +3 -1
  115. package/dist/reply-dedup.js.map +1 -1
  116. package/dist/restart-guard.d.ts +1 -0
  117. package/dist/restart-guard.js +8 -3
  118. package/dist/restart-guard.js.map +1 -1
  119. package/dist/service-activation.d.ts +15 -2
  120. package/dist/service-activation.js +81 -4
  121. package/dist/service-activation.js.map +1 -1
  122. package/dist/service-installer.js +19 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +53 -12
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/settings-authority.d.ts +11 -0
  127. package/dist/settings-authority.js +128 -0
  128. package/dist/settings-authority.js.map +1 -0
  129. package/dist/settings-change.d.ts +5 -0
  130. package/dist/settings-change.js +4 -3
  131. package/dist/settings-change.js.map +1 -1
  132. package/dist/settings-confirmation.d.ts +3 -0
  133. package/dist/settings-confirmation.js +7 -1
  134. package/dist/settings-confirmation.js.map +1 -1
  135. package/dist/settings-effect.js +19 -7
  136. package/dist/settings-effect.js.map +1 -1
  137. package/dist/settings-http-confirmation.js +1 -1
  138. package/dist/settings-http-confirmation.js.map +1 -1
  139. package/dist/settings-redaction.d.ts +18 -0
  140. package/dist/settings-redaction.js +58 -0
  141. package/dist/settings-redaction.js.map +1 -0
  142. package/dist/setup-form.d.ts +9 -1
  143. package/dist/setup-form.js +25 -1
  144. package/dist/setup-form.js.map +1 -1
  145. package/dist/setup-host.js +13 -3
  146. package/dist/setup-host.js.map +1 -1
  147. package/dist/setup-wizard.js +26 -19
  148. package/dist/setup-wizard.js.map +1 -1
  149. package/dist/systemd-runtime.d.ts +15 -0
  150. package/dist/systemd-runtime.js +41 -0
  151. package/dist/systemd-runtime.js.map +1 -0
  152. package/dist/tmux-control.d.ts +22 -0
  153. package/dist/tmux-control.js +82 -17
  154. package/dist/tmux-control.js.map +1 -1
  155. package/dist/token-env-name.d.ts +37 -0
  156. package/dist/token-env-name.js +93 -0
  157. package/dist/token-env-name.js.map +1 -0
  158. package/dist/tool-permissions.js +5 -3
  159. package/dist/tool-permissions.js.map +1 -1
  160. package/dist/topic-commands.d.ts +7 -16
  161. package/dist/topic-commands.js +48 -84
  162. package/dist/topic-commands.js.map +1 -1
  163. package/dist/transcript-monitor.js +3 -0
  164. package/dist/transcript-monitor.js.map +1 -1
  165. package/dist/transcript-sources.d.ts +68 -0
  166. package/dist/transcript-sources.js +336 -29
  167. package/dist/transcript-sources.js.map +1 -1
  168. package/dist/transcript-turns.d.ts +39 -0
  169. package/dist/transcript-turns.js +58 -0
  170. package/dist/transcript-turns.js.map +1 -0
  171. package/dist/tunnel/cloudflared-install.d.ts +42 -0
  172. package/dist/tunnel/cloudflared-install.js +194 -9
  173. package/dist/tunnel/cloudflared-install.js.map +1 -1
  174. package/dist/tunnel/deb-extract.d.ts +9 -0
  175. package/dist/tunnel/deb-extract.js +94 -0
  176. package/dist/tunnel/deb-extract.js.map +1 -0
  177. package/dist/turn-reply-guard.d.ts +3 -0
  178. package/dist/turn-reply-guard.js.map +1 -1
  179. package/dist/ui/app.html +3 -0
  180. package/dist/ui/chat-render.js +32 -6
  181. package/dist/ui/chat-store.js +21 -1
  182. package/dist/ui/chat-strings.js +45 -0
  183. package/dist/ui/chat-thread.js +109 -52
  184. package/dist/ui/first-run.js +64 -0
  185. package/dist/ui/html-attachment.js +26 -0
  186. package/dist/ui/icons/apple-touch-icon.png +0 -0
  187. package/dist/ui/icons/favicon.ico +0 -0
  188. package/dist/ui/icons/favicon.svg +8 -0
  189. package/dist/ui/panel-chat.js +16 -7
  190. package/dist/ui/panel-fleet.js +5 -5
  191. package/dist/ui/panel-settings.js +18 -8
  192. package/dist/ui/preview-panel.js +47 -7
  193. package/dist/ui/preview.js +36 -13
  194. package/dist/ui/settings-apply.js +3 -1
  195. package/dist/ui/settings-dialogs.js +95 -25
  196. package/dist/ui/settings-discord.js +130 -0
  197. package/dist/ui/settings-model.js +21 -0
  198. package/dist/ui/settings-strings.js +46 -6
  199. package/dist/ui/settings-token.js +54 -0
  200. package/dist/ui/settings-wizard.js +63 -29
  201. package/dist/ui/shared/app-shell.js +19 -33
  202. package/dist/ui/shared/app-store.js +5 -3
  203. package/dist/ui/shared/app-stream.js +2 -1
  204. package/dist/ui/shared/app.css +34 -2
  205. package/dist/ui/shared/app.js +2 -1
  206. package/dist/ui/shared/instance-nav.js +310 -0
  207. package/dist/ui/shared/panel-view.js +12 -190
  208. package/dist/ui/shared/view-roster-store.js +5 -0
  209. package/dist/ui/shared/view-strings.js +12 -0
  210. package/dist/ui/signin.html +3 -0
  211. package/dist/update-dispatch.d.ts +1 -1
  212. package/dist/update-dispatch.js +1 -1
  213. package/dist/update-install.d.ts +15 -3
  214. package/dist/update-install.js +41 -13
  215. package/dist/update-install.js.map +1 -1
  216. package/dist/update-launch.d.ts +22 -0
  217. package/dist/update-launch.js +106 -0
  218. package/dist/update-launch.js.map +1 -0
  219. package/dist/update-marker.js +3 -8
  220. package/dist/update-marker.js.map +1 -1
  221. package/dist/usage/format-rich.js +7 -1
  222. package/dist/usage/format-rich.js.map +1 -1
  223. package/dist/usage/i18n-keys.d.ts +1 -1
  224. package/dist/usage/i18n-keys.js +1 -1
  225. package/dist/usage/i18n-keys.js.map +1 -1
  226. package/dist/usage/providers.d.ts +4 -0
  227. package/dist/usage/providers.js +2 -1
  228. package/dist/usage/providers.js.map +1 -1
  229. package/dist/usage/usage-api.js +48 -2
  230. package/dist/usage/usage-api.js.map +1 -1
  231. package/dist/view-api.d.ts +40 -0
  232. package/dist/view-api.js +28 -10
  233. package/dist/view-api.js.map +1 -1
  234. package/dist/web-api.d.ts +8 -0
  235. package/dist/web-api.js +53 -17
  236. package/dist/web-api.js.map +1 -1
  237. package/dist/web-auth.d.ts +8 -0
  238. package/dist/web-auth.js +34 -8
  239. package/dist/web-auth.js.map +1 -1
  240. package/dist/web-chat-history.d.ts +21 -0
  241. package/dist/web-chat-history.js +44 -0
  242. package/dist/web-chat-history.js.map +1 -1
  243. package/dist/web-host-guard.d.ts +18 -2
  244. package/dist/web-host-guard.js +30 -5
  245. package/dist/web-host-guard.js.map +1 -1
  246. package/dist/web-icons.d.ts +21 -0
  247. package/dist/web-icons.js +38 -0
  248. package/dist/web-icons.js.map +1 -0
  249. package/dist/web-preview.d.ts +9 -0
  250. package/dist/web-preview.js +15 -9
  251. package/dist/web-preview.js.map +1 -1
  252. package/dist/web-session.d.ts +7 -1
  253. package/dist/web-session.js +23 -2
  254. package/dist/web-session.js.map +1 -1
  255. package/launcher/postinstall.cjs +8 -4
  256. package/package.json +1 -1
  257. package/templates/systemd.service.ejs +3 -3
@@ -12,13 +12,14 @@ import { t } from "/assets/app-i18n.js";
12
12
  import { Icon } from "/assets/ui-icons.js";
13
13
  import "./chat-render.js";
14
14
  import "./preview.js";
15
+ import { loadHtmlAttachment } from "./html-attachment.js";
15
16
 
16
17
  const R = () => globalThis.AgendChatRender;
17
18
  const P = () => globalThis.AgendPreview;
18
19
 
19
20
  // ── The panel's state, one per page (the chat shows one instance at a time) ──
20
21
 
21
- /** open: { key, instance, msgKey, n, code, sender, ts } | null; run: start it on mount (a moved run). */
22
+ /** open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4); run: start it on mount (a moved run). */
22
23
  let state = { open: null, run: 0 };
23
24
  const subs = new Set();
24
25
  function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
@@ -54,6 +55,22 @@ export function newerVersion(open, msgs, keyOf) {
54
55
  if (!open || !Array.isArray(msgs)) return null;
55
56
  const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
56
57
  if (at < 0) return null;
58
+ // An attached file: a later agent reply attaching a file of the same name is its newer version. Its HTML is read
59
+ // when the person picks it (code: null here), as on a card.
60
+ if (open.att) {
61
+ const name = String(open.att.name || "").trim().toLowerCase();
62
+ for (let i = msgs.length - 1; i > at; i--) {
63
+ const x = msgs[i];
64
+ if (x.role !== "agent" || !Array.isArray(x.attachments)) continue;
65
+ for (let k = x.attachments.length - 1; k >= 0; k--) {
66
+ const a = x.attachments[k];
67
+ if (!R().isHtmlAttachment(a) || a.name.trim().toLowerCase() !== name) continue;
68
+ const msgKey = keyOf(x);
69
+ return { key: `${msgKey}:a${a.id}`, instance: open.instance, msgKey, att: { id: a.id, name: a.name, size: a.size }, code: null, sender: x.sender, ts: x.ts };
70
+ }
71
+ }
72
+ return null;
73
+ }
57
74
  const title = htmlTitle(open.code);
58
75
  for (let i = msgs.length - 1; i > at; i--) {
59
76
  const x = msgs[i];
@@ -88,11 +105,14 @@ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digi
88
105
  * props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
89
106
  * reveal(msgKey) (the thread shows that message), download(code).
90
107
  */
91
- export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download }) {
108
+ /** allow(): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then setOptIn) → the choice. */
109
+ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download, allow }) {
92
110
  const [st, setSt] = useState(state);
93
111
  const [view, setView] = useState("preview");
94
112
  const [run, setRun] = useState({ state: "idle", reason: "" });
95
113
  const [newer, setNewer] = useState(null);
114
+ const [newerNote, setNewerNote] = useState("");
115
+ const reading = useRef(null); // the newer version whose file is being read (one at a time)
96
116
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
97
117
  const open = st.open && st.open.instance === name ? st.open : null;
98
118
 
@@ -120,6 +140,7 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
120
140
  }, [open && open.key, st.run]);
121
141
  // A later reply with a newer version of this block: offered, not swapped in.
122
142
  useEffect(() => {
143
+ setNewerNote(""); reading.current = null;
123
144
  if (!open) { setNewer(null); return undefined; }
124
145
  const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
125
146
  check();
@@ -162,13 +183,32 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
162
183
  el.addEventListener("pointerup", up);
163
184
  el.addEventListener("pointercancel", up);
164
185
  };
186
+ // Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
187
+ const showNewer = async () => {
188
+ if (!newer || reading.current) return;
189
+ if (newer.code != null) { openPanel(newer); return; }
190
+ const was = open && open.key, mine = reading.current = newer;
191
+ setNewerNote(t("chat.pvLoading"));
192
+ const r = await loadHtmlAttachment(newer.att);
193
+ if (reading.current !== mine) return;
194
+ reading.current = null;
195
+ const now = panelStore.get().open;
196
+ if (!now || now.key !== was) return;
197
+ if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
198
+ openPanel({ ...newer, code: r.code });
199
+ };
200
+ const fileName = open && open.att ? open.att.name : undefined;
165
201
  // Esc in the panel closes it (and is not the chat's "stop the reply").
166
202
  const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
167
203
 
168
204
  if (!open) return null;
169
205
  const going = run.state === "starting" || run.state === "running";
170
206
  const a = P().availability();
171
- const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : a.reason);
207
+ // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm); Preview
208
+ // is then enabled — still a click. Why a run stopped (e.g. switched off in another tab) is still said first.
209
+ const askOptIn = !going && !a.ok && a.why === "optin" && typeof allow === "function";
210
+ const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : askOptIn ? t("chat.pvWhyOptinPanel") : a.reason);
211
+ const allowHere = async () => { if (await allow()) setRun({ state: "idle", reason: "" }); };
172
212
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
173
213
  return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
174
214
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
@@ -188,12 +228,12 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
188
228
  ${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
189
229
  <button type="button" class="btn btn-sm pv-panel-stop" title=${t("chat.pvStop")} onClick=${stop}><${Icon} name="stop" size=${14} /><span class="lbl">${t("chat.pvStop")}</span></button>`
190
230
  : html`<button type="button" class="btn btn-sm btn-primary pv-panel-run" title=${t("chat.pvPreview")} disabled=${!a.ok} onClick=${start}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvPreview")}</span></button>`}
191
- <button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
231
+ <button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code, fileName)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
192
232
  </div>
193
233
  ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
194
- <button type="button" class="btn btn-sm pv-newer-show" onClick=${() => openPanel(newer)}>${t("chat.pvNewerShow")}</button></div>` : null}
195
- ${going ? html`<div class="pv-banner">${P().BANNER}</div>` : null}
196
- ${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
234
+ <button type="button" class="btn btn-sm pv-newer-show" onClick=${showNewer}>${t("chat.pvNewerShow")}</button>${newerNote ? html`<span class="pv-newer-note">${newerNote}</span>` : null}</div>` : null}
235
+ ${going ? html`<div class="pv-banner">${P().banner()}</div>` : null}
236
+ ${note && view === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
197
237
  <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
198
238
  <div class="pv-holder" ref=${holder}></div>
199
239
  ${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
@@ -25,7 +25,18 @@
25
25
  var NEVER_KEY = "agend_html_preview_never"; // sessionStorage: "1" — never preview on this device (this session)
26
26
  var BANNER = "Previews run the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out. Only preview content you trust. A preview can slow or freeze this tab.";
27
27
 
28
- var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "" };
28
+ var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "", reasonCode: "" };
29
+ // #1554: the page's words. `say(key, english, …values)` asks the app's dictionary (init's opts.text) and keeps the
30
+ // English here when it has none — so this file still works, and reads the same, without the app.
31
+ var text = null;
32
+ function say(key, english) {
33
+ var values = Array.prototype.slice.call(arguments, 2);
34
+ var s = null;
35
+ if (text) { try { s = text.apply(null, [key].concat(values)); } catch (e) { s = null; } }
36
+ if (typeof s === "string" && s && s !== key) return s;
37
+ for (var i = 0; i < values.length; i++) english = english.split("{" + i + "}").join(String(values[i]));
38
+ return english;
39
+ }
29
40
  var changed = []; // the page's "this device's choice changed" callbacks (checkbox, cards)
30
41
  var live = new Map(); // cardKey → { key, iframe, ch, holder, state, … } (at most one: one running preview per page)
31
42
  var listening = false;
@@ -41,14 +52,19 @@
41
52
  return s;
42
53
  }
43
54
 
44
- /** What the server said about this page load (the <body data-*> attributes /ui was served with). */
45
- function init(data) {
55
+ /**
56
+ * What the server said about this page load (the <body data-*> attributes /ui was served with). opts.text(key,
57
+ * …values): the app's dictionary for this file's words (#1554); without it they are the English below.
58
+ */
59
+ function init(data, opts) {
46
60
  cfg = {
47
61
  dashboardOrigin: String((data && data.dashboardOrigin) || ""),
48
62
  previewOrigin: String((data && data.previewOrigin) || ""),
49
63
  boot: String((data && data.previewBoot) || ""),
50
64
  reason: String((data && data.previewReason) || ""),
65
+ reasonCode: String((data && data.previewReasonCode) || ""),
51
66
  };
67
+ if (opts && typeof opts.text === "function") text = opts.text;
52
68
  if (!listening && typeof root.addEventListener === "function") {
53
69
  root.addEventListener("message", onMessage);
54
70
  // The opt-in is per device: another tab of this dashboard turning it off (or clearing storage) stops the
@@ -96,13 +112,18 @@
96
112
  * a frame from the wrong place), and the device must have opted in and not switched previews off.
97
113
  */
98
114
  function availability() {
99
- if (!cfg.previewOrigin || !cfg.boot) return { ok: false, why: "server", reason: cfg.reason || "Previews are not available here." };
115
+ if (!cfg.previewOrigin || !cfg.boot) {
116
+ // The server's reason, in the page's language when the server named it (a code), else as the server wrote it.
117
+ var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, cfg.reason || "Previews are not available here.", cfg.dashboardOrigin) : "";
118
+ return { ok: false, why: "server", reason: server || cfg.reason || say("pvWhyServer", "Previews are not available here.") };
119
+ }
100
120
  var at = root.location && root.location.origin;
101
121
  if (!cfg.dashboardOrigin || at !== cfg.dashboardOrigin) {
102
- return { ok: false, why: "origin", reason: "Previews are off: this page is at " + at + ", but the fleet believes it is at " + (cfg.dashboardOrigin || "an unknown address") + " (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin." };
122
+ return { ok: false, why: "origin", reason: say("pvWhyOrigin", "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
123
+ at, cfg.dashboardOrigin || say("pvUnknownAddress", "an unknown address")) };
103
124
  }
104
- if (never()) return { ok: false, why: "never", reason: "Previews are switched off on this device for this session." };
105
- if (!optedIn()) return { ok: false, why: "optin", reason: "Previews are off on this device. Allow them from the card's menu or the sidebar." };
125
+ if (never()) return { ok: false, why: "never", reason: say("pvWhyNever", "Previews are switched off on this device for this session.") };
126
+ if (!optedIn()) return { ok: false, why: "optin", reason: say("pvWhyOptin", "Previews are off on this device. Allow them from the card's menu or the sidebar.") };
106
127
  return { ok: true, why: "", reason: "" };
107
128
  }
108
129
 
@@ -134,7 +155,7 @@
134
155
  function start(key, holder, html, ui, opts) {
135
156
  var a = availability();
136
157
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
137
- if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
158
+ if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 1 MiB; it is not previewed.")); return false; }
138
159
  stopAll("another");
139
160
  var doc = holder.ownerDocument;
140
161
  var iframe = mountPreview(doc);
@@ -146,7 +167,7 @@
146
167
  if (card.fill) iframe.classList.add("fill");
147
168
  live.set(key, card);
148
169
  card.readyTimer = root.setTimeout(function () {
149
- if (live.get(key) === card && !card.rendered) stop(key, "unavailable", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too.");
170
+ if (live.get(key) === card && !card.rendered) stop(key, "unavailable", say("pvNoAnswer", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too."));
150
171
  }, LIMITS.readyMs);
151
172
  holder.appendChild(iframe);
152
173
  if (!card.fill) setHeight(card, LIMITS.minHeight);
@@ -163,7 +184,7 @@
163
184
  // Best effort: a frame that froze this tab freezes this timer too. Only while the page is visible.
164
185
  var hidden = root.document && root.document.visibilityState === "hidden";
165
186
  if (hidden) { armWatchdog(card); return; }
166
- stop(card.key, "stopped", "The preview stopped answering, so it was closed.");
187
+ stop(card.key, "stopped", say("pvStoppedAnswering", "The preview stopped answering, so it was closed."));
167
188
  }, LIMITS.watchdogMs);
168
189
  }
169
190
 
@@ -181,7 +202,7 @@
181
202
  function stopAll(why) {
182
203
  var keys = [];
183
204
  live.forEach(function (_c, k) { keys.push(k); });
184
- for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? "Previews were switched off on this device." : why === "another" ? "Another preview started." : "");
205
+ for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? say("pvSwitchedOff", "Previews were switched off on this device.") : why === "another" ? say("pvAnother", "Another preview started.") : "");
185
206
  return keys;
186
207
  }
187
208
  /** Stop every preview inside `node` (before the chat replaces, moves or removes it); returns the stopped keys. */
@@ -214,7 +235,7 @@
214
235
  if (Object.keys(d).sort().join(",") !== KEYS[d.type]) return;
215
236
  if (d.type === "ready") {
216
237
  if (d.ch !== null || typeof d.boot !== "string" || card.rendered) return;
217
- if (d.boot !== cfg.boot) { stop(card.key, "unavailable", "Preview unavailable: something else answered on the preview port."); return; }
238
+ if (d.boot !== cfg.boot) { stop(card.key, "unavailable", say("pvWrongBoot", "Preview unavailable: something else answered on the preview port.")); return; }
218
239
  if (!availability().ok) { stop(card.key, "unavailable", availability().reason); return; }
219
240
  card.rendered = true;
220
241
  root.clearTimeout(card.readyTimer); card.readyTimer = null;
@@ -227,7 +248,7 @@
227
248
  if (d.type === "heartbeat") {
228
249
  // A heartbeat never outlives the permission: if this device stopped allowing previews (and the storage event
229
250
  // was missed), the next heartbeat ends it.
230
- if (!availability().ok) { stop(card.key, "stopped", "Previews were switched off on this device."); return; }
251
+ if (!availability().ok) { stop(card.key, "stopped", say("pvSwitchedOff", "Previews were switched off on this device.")); return; }
231
252
  armWatchdog(card);
232
253
  return;
233
254
  }
@@ -266,5 +287,7 @@
266
287
  init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
267
288
  start: start, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
268
289
  mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
290
+ /** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
291
+ banner: function () { return say("pvBanner", BANNER); },
269
292
  };
270
293
  });
@@ -26,7 +26,7 @@ function view() {
26
26
  if (!op) return null;
27
27
  return {
28
28
  id: op.id, phase: op.phase, error: op.error, job: op.job, lostJob: op.lostJob, restart: op.restart,
29
- steps: op.steps.map(({ label, impact, status, error, pendingId }) => ({ label, impact, status, error: error || null, pendingId: pendingId || null })),
29
+ steps: op.steps.map(({ label, impact, status, error, pendingId, note }) => ({ label, impact, status, error: error || null, pendingId: pendingId || null, ...(note ? { note } : {}) })),
30
30
  leftover: op.leftover,
31
31
  };
32
32
  }
@@ -138,6 +138,8 @@ async function rotateConnectionSecret(step, o) {
138
138
  if (status && status.ok) result = status.body || result;
139
139
  }
140
140
  const ok = result.result === "applied" || result.result === "restart_required";
141
+ // Stored, but no adapter was running to take it (#1519 P1): said as such — not "done", and never "failed".
142
+ if (result.result === "restart_required") { step.note = t("settings.tokenSavedRestart"); publish(); }
141
143
  return { ok, status: ok ? 200 : 409, body: ok ? result : { error: result.error || result.result || "apply failed" } };
142
144
  }
143
145
 
@@ -10,6 +10,9 @@ import { Icon } from "/assets/ui-icons.js";
10
10
  import { toast } from "/assets/ui-toast.js";
11
11
  import { confirmDialog } from "/assets/ui-confirm.js";
12
12
  import { api, confirmedWrite, newKey } from "./settings-confirm.js";
13
+ import { botHandle, TokenEnvNote, TokenField, verifyBotToken } from "./settings-token.js";
14
+ import { DiscordServerPicker } from "./settings-discord.js";
15
+ import { startOperation } from "./settings-apply.js";
13
16
  import {
14
17
  ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
15
18
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
@@ -341,15 +344,31 @@ export function BotDialog({ id, ctx, onClose }) {
341
344
  const [bindBusy, setBindBusy] = useState(false);
342
345
  const [token, setToken] = useState("");
343
346
  const [tokenNote, setTokenNote] = useState(null);
347
+ // Replace token (#1519 P1): the field opens on Replace, and a token is staged only once the platform has named its bot.
348
+ const [replacing, setReplacing] = useState(false);
349
+ const [tokenId, setTokenId] = useState(null);
350
+ const [tokenBusy, setTokenBusy] = useState(false);
351
+ const status = (ctx.connections || []).find((c) => c && c.id === id) || {};
344
352
  const [emojis, setEmojis] = useState({ value: null, baseline: null });
345
353
  const lease = useLease("bot-dialog");
346
354
  const accessWarn = mode === "locked" && users.length === 0 ? "accessLockedEmpty" : "";
355
+ // The token's revision (#1529 review): typing moves it, and a Verify for an older value is dropped when it lands.
356
+ const tokenRev = useRef(0);
357
+ const verifyToken = async () => {
358
+ setTokenBusy(true);
359
+ const mine = epoch.current, at = tokenRev.current;
360
+ const identity = await verifyBotToken(type, token);
361
+ if (!lease.current() || epoch.current !== mine) return;
362
+ setTokenBusy(false);
363
+ if (tokenRev.current === at) setTokenId(identity);
364
+ };
347
365
  const stageToken = () => {
348
366
  if (!ch) return;
349
367
  if (!token) { setTokenNote({ error: tn("tokenRequired") }); return; }
368
+ if (!tokenId || !tokenId.valid) { setTokenNote({ error: tn("wizardNeedVerify") }); return; }
350
369
  // The token goes into the staged change and nowhere else: not a label, not the URL, not storage.
351
370
  ctx.stage(`secret:${id}`, { label: tn("rotateToken", type), impact: schema.order[0], connectionSecret: { id, secret: token } });
352
- setToken("");
371
+ setToken(""); setTokenId(null); setReplacing(false);
353
372
  setTokenNote({ text: tn("tokenQueued") });
354
373
  };
355
374
  const stage = () => {
@@ -419,45 +438,96 @@ export function BotDialog({ id, ctx, onClose }) {
419
438
  previewBody=${(map) => ({ channel_id: id, platform: type, channel_config: map })} onChange=${setEmojis} />
420
439
  </${Drawer}>
421
440
  <${Drawer} title=${tn("advancedSection")}>
422
- <div class="field"><span>${tn("tokenEnv")}</span><code>${ch.bot_token_env || tn("noTokenEnv")}</code></div>
423
- <div class="field"><label for="bot-token">${tn("tokenField")}</label>
424
- <input id="bot-token" type="password" autocomplete="new-password" value=${token} placeholder=${tn("tokenPlaceholder")} onInput=${(e) => setToken(e.target.value)} />
425
- <p class="note">${tn("tokenHint")}</p>
426
- <div class="dlg-inline-actions"><button type="button" class="btn btn-sm" onClick=${stageToken}>${tn("tokenButton")}</button>
427
- ${tokenNote ? html`<span class=${`feedback${tokenNote.error ? " error" : ""}`} role="status">${tokenNote.error || tokenNote.text}</span>` : null}</div></div>
441
+ <div class="field"><span>${tn("tokenStatus")}</span>
442
+ <div class="dlg-inline-actions"><span class="token-status">${status.token_present === false ? tn("tokenMissing")
443
+ : status.identity && status.identity.username ? tn("tokenSetAs", botHandle(status.identity.username)) : tn("tokenSet")}</span>
444
+ ${replacing ? null : html`<button type="button" class="btn btn-sm" onClick=${() => { setReplacing(true); setTokenNote(null); }}>${tn("tokenReplace")}</button>`}</div></div>
445
+ ${replacing ? html`<${TokenField} id="bot-token" platform=${type} value=${token} identity=${tokenId} busy=${tokenBusy}
446
+ onInput=${(v) => { tokenRev.current++; setToken(v); setTokenId(null); }} onVerify=${verifyToken} hint=${tn("tokenHint")} />
447
+ <div class="dlg-inline-actions"><button type="button" class="btn btn-sm" disabled=${!tokenId || !tokenId.valid} onClick=${stageToken}>${tn("tokenButton")}</button>
448
+ <button type="button" class="btn btn-sm btn-ghost" onClick=${() => { tokenRev.current++; setReplacing(false); setToken(""); setTokenId(null); }}>${tn("cancel")}</button></div>` : null}
449
+ ${tokenNote ? html`<p class=${`feedback${tokenNote.error ? " error" : ""}`} role="status">${tokenNote.error || tokenNote.text}</p>` : null}
450
+ <${TokenEnvNote} name=${ch.bot_token_env} />
428
451
  <div class="dlg-inline-actions"><button type="button" class="btn btn-sm danger" onClick=${remove}><${Icon} name="trash" size=${14} />${tn("deleteBot")}</button></div>
429
452
  </${Drawer}>
430
453
  </${EditDialog}>`;
431
454
  }
432
455
 
433
- /** A new connection: written at once (as before), through the confirmation an admin may have to give. */
456
+ /**
457
+ * A new connection (#1519 P1): the bot token, checked with the platform, then the server-side plan — a free id and a
458
+ * generated token env (shown under Advanced, read-only) — then the same write the setup wizard makes, without an agent:
459
+ * one operation (the confirmation an admin may have to give, then Apply, which offers the restart a new connection
460
+ * needs). Never a replacement: the server refuses an id or token env another connection holds.
461
+ */
434
462
  export function NewBotDialog({ ctx, onClose }) {
435
463
  const lease = useLease("new-bot");
436
- const [f, setF] = useState({ type: "discord", id: "", env: "", group: "" });
464
+ const [f, setF] = useState({ type: "discord", id: "", token: "", identity: null, guilds: [], group: "", general: "" });
465
+ const [plan, setPlan] = useState(null);
437
466
  const [busy, setBusy] = useState(false);
438
467
  const [err, setErr] = useState("");
468
+ // The form's revision (#1529 review): any change to the token, platform, id or group moves it; a Verify or a plan
469
+ // that began before is dropped when it lands — never restoring an old token, naming it, or planning for it.
470
+ const rev = useRef(0);
471
+ const body = (x) => ({ platform: x.type, connection_only: true, ...(x.id.trim() ? { channel_id: x.id.trim() } : {}),
472
+ ...(x.group.trim() ? (x.type === "discord" ? { guild_id: x.group.trim() } : { group_id: x.group.trim() }) : {}),
473
+ ...(x.type === "discord" && x.general.trim() ? { general_channel_id: x.general.trim() } : {}) });
474
+ const planFor = async (x) => {
475
+ const at = rev.current;
476
+ const res = await api("/api/settings/quickstart/plan", { method: "POST", body: JSON.stringify(body(x)) }).catch(() => ({ ok: false, body: {} }));
477
+ if (!lease.current() || rev.current !== at) return null;
478
+ if (!res.ok) { setErr((res.body && res.body.error) || tn("failed")); setPlan(null); return null; }
479
+ setErr(""); setPlan(res.body); return res.body;
480
+ };
481
+ const verify = async () => {
482
+ const at = rev.current, x = f;
483
+ const live = () => lease.current() && rev.current === at;
484
+ setBusy(true); setErr("");
485
+ const identity = await verifyBotToken(x.type, x.token);
486
+ if (!live()) { if (lease.current()) setBusy(false); return; }
487
+ let guilds = [];
488
+ if (identity.valid && x.type === "discord") {
489
+ const g = await api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify({ action: "guilds", token: x.token }) }).catch(() => null);
490
+ if (!live()) { if (lease.current()) setBusy(false); return; }
491
+ guilds = (g && g.body && g.body.guilds) || [];
492
+ }
493
+ // Onto the form as it is now (same revision, so the same token): never a copy of the form from before the await.
494
+ setF((cur) => ({ ...cur, identity, guilds })); setBusy(false);
495
+ if (identity.valid) await planFor({ ...x, identity, guilds });
496
+ };
439
497
  const save = async () => {
440
- if (!f.env.trim()) { setErr(tn("tokenEnvRequired")); return; }
441
- const next = [...channelsOf(ctx.fleet), { id: f.id.trim() || undefined, type: f.type, bot_token_env: f.env.trim(), group_id: f.group.trim() || undefined, access: { mode: "locked", allowed_users: [] } }];
498
+ if (!f.identity || !f.identity.valid) { setErr(tn("wizardNeedVerify")); return; }
499
+ const x = f;
442
500
  setBusy(true);
443
- const res = await confirmedWrite("/api/settings/fleet/channels", { method: "PUT", body: next, label: tn("addBotLabel", f.id.trim() || f.type) }).catch(() => ({ ok: false, body: {} }));
444
- if (!res.ok) { toast((res.body && res.body.error) || tn("failed"), false); if (lease.current()) setBusy(false); return; }
445
- toast(tn("saved"));
446
- ctx.reload();
447
- if (lease.current()) onClose();
501
+ const p = await planFor(x);
502
+ if (!p) { if (lease.current()) setBusy(false); return; }
503
+ const handed = startOperation([{ label: tn("addBotLabel", p.channel_id), impact: impactOf(ctx.schema, "fleet.channels"),
504
+ request: { method: "POST", url: "/api/settings/quickstart/commit",
505
+ body: { ...body(x), channel_id: p.channel_id, token_env: p.token_env, token_env_generated: true, token: x.token }, sensitive: true } }]);
506
+ if (!handed) { setErr(tn("applyBusyLocal")); setBusy(false); return; }
507
+ setF((x) => ({ ...x, token: "" }));
508
+ onClose();
448
509
  };
449
- const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
510
+ // A change of platform or id is another connection: what was verified and planned no longer applies. The server or
511
+ // group does not change the id or the token's name, so the plan (and its name under Advanced) stays.
512
+ const set = (k) => (v) => { rev.current++; setF((x) => ({ ...x, [k]: v, ...(k === "type" ? { identity: null, guilds: [], group: "", general: "" } : {}) })); if (k !== "group" && k !== "general") setPlan(null); };
450
513
  return html`<${Dialog} title=${tn("newBot")} onClose=${onClose} busy=${busy}
451
514
  actions=${html`<button type="button" class="btn" disabled=${busy} onClick=${onClose}>${tn("cancel")}</button>
452
- <button type="button" class="btn btn-primary" disabled=${busy} onClick=${save}>${tn("save")}</button>`}>
515
+ <button type="button" class="btn btn-primary" disabled=${busy || !f.identity || !f.identity.valid} onClick=${save}>${tn("save")}</button>`}>
453
516
  <div class="form">
454
- <div class="grid2">
455
- <div class="field"><label for="nb-type">${tn("type")}</label><${Select} id="nb-type" value=${f.type} onChange=${(v) => setF({ ...f, type: v })} options=${CH_TYPES} /></div>
456
- <div class="field"><label for="nb-id">${tn("adapterId")}</label><input id="nb-id" type="text" placeholder="persona1" value=${f.id} onInput=${set("id")} /></div>
457
- <div class="field"><label for="nb-env">${tn("tokenEnv")} *</label><input id="nb-env" type="text" placeholder="BOT_TOKEN_ENV" value=${f.env} onInput=${set("env")} /></div>
458
- <div class="field"><label for="nb-group">${tn("groupId")}</label><input id="nb-group" type="text" value=${f.group} onInput=${set("group")} /></div>
459
- </div>
460
- <p class="note">${tn("envHint")}</p>
517
+ <div class="field"><label for="nb-type">${tn("type")}</label><${Select} id="nb-type" value=${f.type} onChange=${set("type")} options=${CH_TYPES} /></div>
518
+ <${TokenField} id="nb-token" platform=${f.type} value=${f.token} identity=${f.identity} busy=${busy}
519
+ onInput=${(v) => { rev.current++; setF((x) => ({ ...x, token: v, identity: null, guilds: [] })); setPlan(null); }} onVerify=${verify} hint=${tn("newBotTokenHint")} />
520
+ ${f.type === "discord"
521
+ ? html`<${DiscordServerPicker} idPrefix="nb" token=${f.identity && f.identity.valid ? f.token : ""} bot=${f.identity && botHandle(f.identity.username)}
522
+ invite=${f.identity && f.identity.invite} portal=${f.identity && f.identity.portal} guilds=${f.guilds} onGuilds=${(g) => setF((x) => ({ ...x, guilds: g }))}
523
+ guild=${f.group} onGuild=${set("group")} channel=${f.general} onChannel=${set("general")} />`
524
+ : html`<div class="field"><label for="nb-group">${tn("groupIdField")}</label>
525
+ <input id="nb-group" type="text" value=${f.group} onInput=${(e) => set("group")(e.target.value.trim())} /></div>`}
526
+ <${Drawer} title=${tn("advancedSection")}>
527
+ <div class="field"><label for="nb-id">${tn("connectionIdOptional")}</label><input id="nb-id" type="text" placeholder=${plan ? plan.channel_id : f.type}
528
+ value=${f.id} onInput=${(e) => set("id")(e.target.value)} onBlur=${() => { if (f.identity && f.identity.valid) planFor(f); }} /></div>
529
+ <${TokenEnvNote} name=${plan && plan.token_env} />
530
+ </${Drawer}>
461
531
  ${err ? html`<p class="feedback error" role="alert">${err}</p>` : null}
462
532
  </div></${Dialog}>`;
463
533
  }
@@ -0,0 +1,130 @@
1
+ // #1519 P3 (docs/design/ux-onboarding-walkthrough.md §5.3): after a Discord token verifies — the invite (one permission
2
+ // set, never Administrator), the Message Content note with the portal's Bot page, the server the bot joins found by
3
+ // asking Discord every 2 s for up to 2 minutes, and the server's text channels as a list ("general" first choice). The
4
+ // setup wizard and New connection use it.
5
+ //
6
+ // Every read is an operation of its own (#1533 review): numbered when it starts, and its answer is taken only while it is
7
+ // still the current one of its kind and this picker is still on the page. A new token, another server, a manual choice,
8
+ // Stop, the 2-minute limit or leaving the page each retire what was asked before — an answer about A never lands after
9
+ // B, nor after A again (an older A), nor once the page is gone.
10
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
+ import { t } from "/assets/app-i18n.js";
12
+ import { api } from "./settings-confirm.js";
13
+ import { Select } from "./settings-dialogs.js";
14
+
15
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
16
+ export const POLL_MS = 2000, POLL_FOR_MS = 120_000;
17
+ const clock = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); // elapsed time: monotonic
18
+ const probe = (body) => api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify(body) }).catch(() => ({ ok: false, body: {} }));
19
+
20
+ /**
21
+ * props: idPrefix, token, bot (@name), invite, portal, guilds, onGuilds(list), guild, onGuild(id), channel, onChannel(id).
22
+ * The parent owns the values; this owns the asking.
23
+ */
24
+ export function DiscordServerPicker({ idPrefix, token, bot, invite, portal, guilds, onGuilds, guild, onGuild, channel, onChannel }) {
25
+ const [polling, setPolling] = useState(null); // { timedOut } while waiting for the bot to join
26
+ const [channels, setChannels] = useState(null); // null: not asked; { list } | { error }
27
+ const live = useRef(true);
28
+ const now = useRef({ token, guild, channel });
29
+ now.current = { token, guild, channel };
30
+ // The current operation of each kind; an answer is taken only while its number is still the one here.
31
+ // `choice`: the one generation of the server choice that polls and Check again both write (#1533 review r2) — a read
32
+ // started before any later pick, Check again or Stop cannot pick.
33
+ const op = useRef({ poll: 0, check: 0, channels: 0, choice: 0 });
34
+ const poll = useRef({ timer: null, until: 0, known: new Set(), asking: false });
35
+ const chosen = useRef(false); // the person picked a General channel since this server's list was asked
36
+
37
+ const stopPolling = (timedOut = false) => {
38
+ op.current.poll++; op.current.choice++; // retires the poll's reads still on their way
39
+ if (poll.current.timer) { clearInterval(poll.current.timer); poll.current.timer = null; }
40
+ setPolling(timedOut ? { timedOut: true } : null);
41
+ };
42
+ useEffect(() => () => { live.current = false; if (poll.current.timer) clearInterval(poll.current.timer); }, []);
43
+ // A new token is another bot: what was being waited for, or listed, was about the old one — and so was its General.
44
+ const lastToken = useRef(token);
45
+ useEffect(() => {
46
+ op.current.check++; op.current.channels++; stopPolling(); setChannels(null);
47
+ if (lastToken.current !== token && now.current.channel) onChannel("");
48
+ lastToken.current = token;
49
+ }, [token]);
50
+
51
+ /** One read of the bot's servers, for operation `kind` number `seq`; joined: pick it (unless the person chose meanwhile). */
52
+ const readGuilds = async (kind, seq, known, deadline) => {
53
+ const asked = token, choice = op.current.choice;
54
+ const res = await probe({ action: "guilds", token: asked });
55
+ const current = () => live.current && op.current[kind] === seq && op.current.choice === choice && now.current.token === asked
56
+ && (deadline == null || clock() <= deadline);
57
+ if (!current()) return false;
58
+ const list = (res.ok && res.body && Array.isArray(res.body.guilds)) ? res.body.guilds : null;
59
+ if (!list) return false;
60
+ onGuilds(list);
61
+ const joined = list.filter((g) => !known.has(g.id));
62
+ if (!joined.length) return false;
63
+ if (joined.length === 1 || !now.current.guild) pickGuild(joined[0].id, false);
64
+ return true;
65
+ };
66
+ const startPolling = () => {
67
+ stopPolling();
68
+ const seq = op.current.poll, known = new Set((guilds || []).map((g) => g.id)), until = clock() + POLL_FOR_MS;
69
+ poll.current = { timer: null, until, known, asking: false };
70
+ setPolling({ timedOut: false });
71
+ const record = poll.current;
72
+ record.timer = setInterval(async () => {
73
+ if (op.current.poll !== seq) return;
74
+ if (clock() > until) { stopPolling(true); return; }
75
+ if (record.asking) return; // one read at a time
76
+ record.asking = true;
77
+ // Its own record only: a later poll's in-flight flag is not this read's to clear.
78
+ await readGuilds("poll", seq, known, until).finally(() => { record.asking = false; });
79
+ }, POLL_MS);
80
+ };
81
+ // Check again: a new, explicit read of its own (it does not revive a stopped poll).
82
+ // A newer, explicit read: it supersedes any read still on its way (theirs no longer picks).
83
+ const checkAgain = () => { op.current.choice++; const seq = ++op.current.check; void readGuilds("check", seq, poll.current.known, null); };
84
+
85
+ /** A server is picked — by the person (`manual`) or because the bot joined it. Either way the old General goes. */
86
+ const pickGuild = (id, manual) => {
87
+ // Any pick — the person's, or a read's — ends the waiting and retires every other read still on its way.
88
+ stopPolling(); op.current.check++;
89
+ if (id !== now.current.guild && now.current.channel) onChannel("");
90
+ onGuild(id);
91
+ };
92
+ const pickChannel = (id) => { chosen.current = true; onChannel(id); };
93
+
94
+ // The chosen server's text channels; "general" when there is one and none is chosen yet.
95
+ useEffect(() => {
96
+ const seq = ++op.current.channels;
97
+ chosen.current = false;
98
+ setChannels(null);
99
+ if (!guild || !token) return;
100
+ const askedToken = token, askedGuild = guild;
101
+ (async () => {
102
+ const res = await probe({ action: "channels", token: askedToken, guild_id: askedGuild });
103
+ if (!live.current || op.current.channels !== seq || now.current.token !== askedToken || now.current.guild !== askedGuild) return;
104
+ if (!res.ok || !res.body || !Array.isArray(res.body.channels)) { setChannels({ error: (res.body && res.body.error) || tn("failed") }); return; }
105
+ setChannels({ list: res.body.channels });
106
+ // The first choice only fills an empty choice the person has not made.
107
+ if (!chosen.current && !now.current.channel) {
108
+ const general = res.body.channels.find((c) => c.name.toLowerCase() === "general");
109
+ if (general) onChannel(general.id);
110
+ }
111
+ })();
112
+ }, [guild, token]);
113
+
114
+ return html`<div class="discord-setup">
115
+ ${invite ? html`<div class="dlg-inline-actions">
116
+ <a class="btn btn-sm btn-primary" href=${invite} target="_blank" rel="noopener noreferrer" onClick=${startPolling}>${tn("discordInvite", bot || tn("discordTheBot"))}</a>
117
+ ${polling && !polling.timedOut ? html`<span class="note" role="status">${tn("discordWaitingJoin")}</span>` : null}
118
+ ${polling ? html`<button type="button" class="btn btn-sm btn-ghost" onClick=${checkAgain}>${tn("discordCheckAgain")}</button>` : null}</div>
119
+ <p class="note">${tn("discordIntentNote")} ${portal ? html`<a href=${portal} target="_blank" rel="noopener noreferrer">${tn("discordPortalBot")}</a>` : null}</p>` : null}
120
+ <div class="field"><label for=${`${idPrefix}-guild`}>${tn("guildIdField")}</label>
121
+ ${guilds && guilds.length
122
+ ? 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>
124
+ <div class="field"><label for=${`${idPrefix}-gen`}>${tn("wizardGeneralChannel")}</label>
125
+ ${channels && channels.list && channels.list.length
126
+ ? 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())} />`}
128
+ ${channels && channels.error ? html`<p class="feedback warning">${channels.error}</p>` : null}</div>
129
+ </div>`;
130
+ }
@@ -275,3 +275,24 @@ export function fullModelRequests(model, current) {
275
275
  }
276
276
  return out;
277
277
  }
278
+
279
+ /**
280
+ * #1519 P6 (design §5.6): one connection's own state, from its own metadata (GET /api/settings/connections) — not the
281
+ * fleet's. { key, cls, bot }: key names the settings string, cls the dot ("ok" | "warn" | "bad" | "off"), bot its @name.
282
+ * In order: no fleet answering, no token, a named problem, then the adapter's own status.
283
+ */
284
+ export function connectionState(meta, fleetUp) {
285
+ const m = meta || {};
286
+ if (!fleetUp) return { key: "problem", cls: "bad" };
287
+ if (m.token_present === false) return { key: "connTokenMissing", cls: "bad" };
288
+ if (m.problem === "missing_intent") return { key: "discordMissingIntent", cls: "bad" };
289
+ if (m.problem === "rejected") return { key: "connRejected", cls: "bad" };
290
+ const bot = m.identity && m.identity.username ? `@${String(m.identity.username).replace(/^@/, "")}` : "";
291
+ switch (m.status) {
292
+ case "connected": return { key: bot ? "connConnectedAs" : "connected", cls: "ok", bot };
293
+ case "retrying": return { key: "connReconnecting", cls: "warn" };
294
+ case "starting": return { key: "connStarting", cls: "warn" };
295
+ case "failed": return { key: "connFailed", cls: "bad" };
296
+ default: return { key: "connNotRunning", cls: "off" };
297
+ }
298
+ }