@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.3

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 (317) hide show
  1. package/README.md +6 -4
  2. package/README.zh-TW.md +6 -4
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +4 -1
  6. package/dist/agent-endpoint.js +82 -14
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-token-header.d.ts +13 -0
  9. package/dist/agent-token-header.js +16 -0
  10. package/dist/agent-token-header.js.map +1 -0
  11. package/dist/atomic-write.d.ts +21 -0
  12. package/dist/atomic-write.js +65 -0
  13. package/dist/atomic-write.js.map +1 -0
  14. package/dist/auth-api.js +2 -0
  15. package/dist/auth-api.js.map +1 -1
  16. package/dist/backend/antigravity.js +4 -3
  17. package/dist/backend/antigravity.js.map +1 -1
  18. package/dist/backend/codex.d.ts +7 -0
  19. package/dist/backend/codex.js +7 -0
  20. package/dist/backend/codex.js.map +1 -1
  21. package/dist/backend/grok.js +1 -1
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/kiro.d.ts +7 -0
  24. package/dist/backend/kiro.js +82 -33
  25. package/dist/backend/kiro.js.map +1 -1
  26. package/dist/backend/muse.d.ts +7 -0
  27. package/dist/backend/muse.js +8 -1
  28. package/dist/backend/muse.js.map +1 -1
  29. package/dist/backend/types.d.ts +12 -0
  30. package/dist/backend/types.js.map +1 -1
  31. package/dist/binary-probe.d.ts +80 -0
  32. package/dist/binary-probe.js +182 -0
  33. package/dist/binary-probe.js.map +1 -0
  34. package/dist/cache-analysis.d.ts +92 -0
  35. package/dist/cache-analysis.js +147 -0
  36. package/dist/cache-analysis.js.map +1 -0
  37. package/dist/cache-ledger.d.ts +108 -0
  38. package/dist/cache-ledger.js +230 -0
  39. package/dist/cache-ledger.js.map +1 -0
  40. package/dist/cache-prices.d.ts +32 -0
  41. package/dist/cache-prices.js +73 -0
  42. package/dist/cache-prices.js.map +1 -0
  43. package/dist/cache-scan.d.ts +39 -0
  44. package/dist/cache-scan.js +115 -0
  45. package/dist/cache-scan.js.map +1 -0
  46. package/dist/cache-service.d.ts +131 -0
  47. package/dist/cache-service.js +452 -0
  48. package/dist/cache-service.js.map +1 -0
  49. package/dist/channel/adapters/discord.d.ts +5 -1
  50. package/dist/channel/adapters/discord.js +56 -11
  51. package/dist/channel/adapters/discord.js.map +1 -1
  52. package/dist/channel/adapters/telegram.d.ts +38 -1
  53. package/dist/channel/adapters/telegram.js +82 -0
  54. package/dist/channel/adapters/telegram.js.map +1 -1
  55. package/dist/channel/mcp-tools.js +1 -1
  56. package/dist/channel/mcp-tools.js.map +1 -1
  57. package/dist/channel/tool-router.d.ts +8 -1
  58. package/dist/channel/tool-router.js +4 -2
  59. package/dist/channel/tool-router.js.map +1 -1
  60. package/dist/channel/types.d.ts +21 -0
  61. package/dist/cli-entry.d.ts +19 -0
  62. package/dist/cli-entry.js +36 -0
  63. package/dist/cli-entry.js.map +1 -0
  64. package/dist/cli.js +376 -54
  65. package/dist/cli.js.map +1 -1
  66. package/dist/connection-secrets.d.ts +5 -0
  67. package/dist/connection-secrets.js.map +1 -1
  68. package/dist/cpu-profile.js +50 -0
  69. package/dist/cpu-profile.js.map +1 -1
  70. package/dist/daemon.d.ts +18 -0
  71. package/dist/daemon.js +256 -63
  72. package/dist/daemon.js.map +1 -1
  73. package/dist/delivery-outbox.d.ts +1 -0
  74. package/dist/delivery-outbox.js +12 -0
  75. package/dist/delivery-outbox.js.map +1 -1
  76. package/dist/discord-permissions.d.ts +33 -0
  77. package/dist/discord-permissions.js +42 -0
  78. package/dist/discord-permissions.js.map +1 -0
  79. package/dist/env-file.d.ts +11 -0
  80. package/dist/env-file.js +27 -0
  81. package/dist/env-file.js.map +1 -0
  82. package/dist/event-log.d.ts +4 -1
  83. package/dist/event-log.js +6 -3
  84. package/dist/event-log.js.map +1 -1
  85. package/dist/fleet-context.d.ts +5 -0
  86. package/dist/fleet-context.js.map +1 -1
  87. package/dist/fleet-control-audit.d.ts +5 -0
  88. package/dist/fleet-control-audit.js +7 -0
  89. package/dist/fleet-control-audit.js.map +1 -1
  90. package/dist/fleet-level-config.d.ts +8 -1
  91. package/dist/fleet-level-config.js +53 -0
  92. package/dist/fleet-level-config.js.map +1 -1
  93. package/dist/fleet-manager.d.ts +122 -7
  94. package/dist/fleet-manager.js +871 -152
  95. package/dist/fleet-manager.js.map +1 -1
  96. package/dist/fleet-stop-budget.d.ts +22 -0
  97. package/dist/fleet-stop-budget.js +95 -0
  98. package/dist/fleet-stop-budget.js.map +1 -0
  99. package/dist/full-restart.js +2 -3
  100. package/dist/full-restart.js.map +1 -1
  101. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  102. package/dist/install-lock.d.ts +49 -0
  103. package/dist/install-lock.js +200 -0
  104. package/dist/install-lock.js.map +1 -0
  105. package/dist/instance-config-impact.js +3 -2
  106. package/dist/instance-config-impact.js.map +1 -1
  107. package/dist/kiro-transcript-lane.d.ts +8 -4
  108. package/dist/kiro-transcript-lane.js +66 -44
  109. package/dist/kiro-transcript-lane.js.map +1 -1
  110. package/dist/locale.js +96 -0
  111. package/dist/locale.js.map +1 -1
  112. package/dist/next-steps.d.ts +5 -0
  113. package/dist/next-steps.js +26 -0
  114. package/dist/next-steps.js.map +1 -0
  115. package/dist/outbound-handlers.js +8 -0
  116. package/dist/outbound-handlers.js.map +1 -1
  117. package/dist/outbound-schemas.d.ts +4 -0
  118. package/dist/outbound-schemas.js +7 -2
  119. package/dist/outbound-schemas.js.map +1 -1
  120. package/dist/outbox-open-error.d.ts +7 -0
  121. package/dist/outbox-open-error.js +15 -0
  122. package/dist/outbox-open-error.js.map +1 -0
  123. package/dist/package-preimage.d.ts +26 -0
  124. package/dist/package-preimage.js +96 -0
  125. package/dist/package-preimage.js.map +1 -0
  126. package/dist/pane-context-cache.d.ts +25 -0
  127. package/dist/pane-context-cache.js +73 -0
  128. package/dist/pane-context-cache.js.map +1 -0
  129. package/dist/paths.d.ts +0 -7
  130. package/dist/paths.js +8 -2
  131. package/dist/paths.js.map +1 -1
  132. package/dist/provider-probe.d.ts +15 -0
  133. package/dist/provider-probe.js +20 -0
  134. package/dist/provider-probe.js.map +1 -1
  135. package/dist/public-link-progress.d.ts +73 -0
  136. package/dist/public-link-progress.js +190 -0
  137. package/dist/public-link-progress.js.map +1 -0
  138. package/dist/public-web-gateway.js +2 -2
  139. package/dist/public-web-gateway.js.map +1 -1
  140. package/dist/public-web-link.d.ts +6 -1
  141. package/dist/public-web-link.js +87 -15
  142. package/dist/public-web-link.js.map +1 -1
  143. package/dist/quickstart-api.d.ts +93 -20
  144. package/dist/quickstart-api.js +194 -62
  145. package/dist/quickstart-api.js.map +1 -1
  146. package/dist/quickstart.d.ts +15 -0
  147. package/dist/quickstart.js +110 -33
  148. package/dist/quickstart.js.map +1 -1
  149. package/dist/reply-buttons-controller.d.ts +98 -0
  150. package/dist/reply-buttons-controller.js +174 -0
  151. package/dist/reply-buttons-controller.js.map +1 -0
  152. package/dist/reply-buttons.d.ts +113 -0
  153. package/dist/reply-buttons.js +208 -0
  154. package/dist/reply-buttons.js.map +1 -0
  155. package/dist/reply-dedup.js +3 -1
  156. package/dist/reply-dedup.js.map +1 -1
  157. package/dist/restart-guard.d.ts +53 -0
  158. package/dist/restart-guard.js +114 -0
  159. package/dist/restart-guard.js.map +1 -0
  160. package/dist/scheduler/db.js +6 -2
  161. package/dist/scheduler/db.js.map +1 -1
  162. package/dist/service-activation.d.ts +62 -7
  163. package/dist/service-activation.js +237 -55
  164. package/dist/service-activation.js.map +1 -1
  165. package/dist/service-installer.d.ts +56 -1
  166. package/dist/service-installer.js +152 -12
  167. package/dist/service-installer.js.map +1 -1
  168. package/dist/service-plan.d.ts +65 -0
  169. package/dist/service-plan.js +108 -0
  170. package/dist/service-plan.js.map +1 -0
  171. package/dist/settings-api.js +92 -10
  172. package/dist/settings-api.js.map +1 -1
  173. package/dist/settings-authority.d.ts +11 -0
  174. package/dist/settings-authority.js +128 -0
  175. package/dist/settings-authority.js.map +1 -0
  176. package/dist/settings-change.d.ts +5 -0
  177. package/dist/settings-change.js +37 -8
  178. package/dist/settings-change.js.map +1 -1
  179. package/dist/settings-confirmation.d.ts +3 -0
  180. package/dist/settings-confirmation.js +7 -1
  181. package/dist/settings-confirmation.js.map +1 -1
  182. package/dist/settings-effect.js +19 -7
  183. package/dist/settings-effect.js.map +1 -1
  184. package/dist/settings-http-confirmation.js +1 -1
  185. package/dist/settings-http-confirmation.js.map +1 -1
  186. package/dist/settings-redaction.d.ts +18 -0
  187. package/dist/settings-redaction.js +58 -0
  188. package/dist/settings-redaction.js.map +1 -0
  189. package/dist/setup-form.d.ts +9 -1
  190. package/dist/setup-form.js +25 -1
  191. package/dist/setup-form.js.map +1 -1
  192. package/dist/setup-host.js +15 -6
  193. package/dist/setup-host.js.map +1 -1
  194. package/dist/setup-wizard.js +27 -19
  195. package/dist/setup-wizard.js.map +1 -1
  196. package/dist/sqlite-open-errors.d.ts +12 -0
  197. package/dist/sqlite-open-errors.js +13 -0
  198. package/dist/sqlite-open-errors.js.map +1 -0
  199. package/dist/systemd-runtime.d.ts +15 -0
  200. package/dist/systemd-runtime.js +41 -0
  201. package/dist/systemd-runtime.js.map +1 -0
  202. package/dist/tmux-control.d.ts +44 -0
  203. package/dist/tmux-control.js +170 -21
  204. package/dist/tmux-control.js.map +1 -1
  205. package/dist/token-env-name.d.ts +37 -0
  206. package/dist/token-env-name.js +93 -0
  207. package/dist/token-env-name.js.map +1 -0
  208. package/dist/tool-permissions.js +5 -3
  209. package/dist/tool-permissions.js.map +1 -1
  210. package/dist/topic-commands.d.ts +7 -16
  211. package/dist/topic-commands.js +56 -85
  212. package/dist/topic-commands.js.map +1 -1
  213. package/dist/transcript-monitor.js +3 -0
  214. package/dist/transcript-monitor.js.map +1 -1
  215. package/dist/transcript-sources.d.ts +68 -0
  216. package/dist/transcript-sources.js +336 -29
  217. package/dist/transcript-sources.js.map +1 -1
  218. package/dist/transcript-turns.d.ts +39 -0
  219. package/dist/transcript-turns.js +58 -0
  220. package/dist/transcript-turns.js.map +1 -0
  221. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  222. package/dist/tunnel/cloudflared-install.js +15 -1
  223. package/dist/tunnel/cloudflared-install.js.map +1 -1
  224. package/dist/turn-reply-guard.d.ts +3 -0
  225. package/dist/turn-reply-guard.js.map +1 -1
  226. package/dist/ui/chat-commands.js +43 -0
  227. package/dist/ui/chat-render.js +34 -8
  228. package/dist/ui/chat-store.js +51 -2
  229. package/dist/ui/chat-strings.js +30 -2
  230. package/dist/ui/chat-thread.js +141 -47
  231. package/dist/ui/first-run.js +64 -0
  232. package/dist/ui/fleet-cache.js +168 -0
  233. package/dist/ui/html-attachment.js +26 -0
  234. package/dist/ui/panel-chat.js +117 -23
  235. package/dist/ui/panel-fleet.js +31 -17
  236. package/dist/ui/panel-settings.js +29 -15
  237. package/dist/ui/preview-panel.js +237 -0
  238. package/dist/ui/preview.js +7 -3
  239. package/dist/ui/settings-apply.js +3 -1
  240. package/dist/ui/settings-dialogs.js +95 -25
  241. package/dist/ui/settings-discord.js +130 -0
  242. package/dist/ui/settings-model.js +21 -0
  243. package/dist/ui/settings-strings.js +50 -8
  244. package/dist/ui/settings-token.js +54 -0
  245. package/dist/ui/settings-wizard.js +63 -29
  246. package/dist/ui/shared/app-route.js +1 -1
  247. package/dist/ui/shared/app-shell.js +25 -37
  248. package/dist/ui/shared/app-store.js +5 -3
  249. package/dist/ui/shared/app-stream.js +2 -1
  250. package/dist/ui/shared/app.css +181 -5
  251. package/dist/ui/shared/app.js +2 -1
  252. package/dist/ui/shared/instance-nav.js +310 -0
  253. package/dist/ui/shared/panel-view.js +12 -190
  254. package/dist/ui/shared/signin.js +13 -1
  255. package/dist/ui/shared/ui-icons.js +2 -0
  256. package/dist/ui/shared/view-roster-store.js +5 -0
  257. package/dist/ui/shared/view-strings.js +12 -0
  258. package/dist/ui/signin.html +2 -1
  259. package/dist/update-dispatch.d.ts +28 -0
  260. package/dist/update-dispatch.js +73 -0
  261. package/dist/update-dispatch.js.map +1 -0
  262. package/dist/update-install.d.ts +78 -8
  263. package/dist/update-install.js +158 -30
  264. package/dist/update-install.js.map +1 -1
  265. package/dist/update-launch.d.ts +22 -0
  266. package/dist/update-launch.js +106 -0
  267. package/dist/update-launch.js.map +1 -0
  268. package/dist/update-marker.js +3 -8
  269. package/dist/update-marker.js.map +1 -1
  270. package/dist/usage/format-rich.js +7 -1
  271. package/dist/usage/format-rich.js.map +1 -1
  272. package/dist/usage/i18n-keys.d.ts +1 -1
  273. package/dist/usage/i18n-keys.js +1 -1
  274. package/dist/usage/i18n-keys.js.map +1 -1
  275. package/dist/usage/providers.d.ts +4 -0
  276. package/dist/usage/providers.js +2 -1
  277. package/dist/usage/providers.js.map +1 -1
  278. package/dist/usage/usage-api.js +48 -2
  279. package/dist/usage/usage-api.js.map +1 -1
  280. package/dist/view-api.d.ts +40 -0
  281. package/dist/view-api.js +28 -10
  282. package/dist/view-api.js.map +1 -1
  283. package/dist/web-api.d.ts +22 -0
  284. package/dist/web-api.js +115 -16
  285. package/dist/web-api.js.map +1 -1
  286. package/dist/web-auth.d.ts +8 -0
  287. package/dist/web-auth.js +34 -8
  288. package/dist/web-auth.js.map +1 -1
  289. package/dist/web-chat-history.d.ts +21 -0
  290. package/dist/web-chat-history.js +44 -0
  291. package/dist/web-chat-history.js.map +1 -1
  292. package/dist/web-commands.d.ts +77 -0
  293. package/dist/web-commands.js +81 -0
  294. package/dist/web-commands.js.map +1 -0
  295. package/dist/web-host-guard.d.ts +18 -2
  296. package/dist/web-host-guard.js +30 -5
  297. package/dist/web-host-guard.js.map +1 -1
  298. package/dist/web-session.d.ts +7 -1
  299. package/dist/web-session.js +23 -2
  300. package/dist/web-session.js.map +1 -1
  301. package/dist/web-shell-routes.d.ts +1 -1
  302. package/dist/web-shell-routes.js +1 -1
  303. package/dist/web-shell-routes.js.map +1 -1
  304. package/launcher/agend +110 -0
  305. package/launcher/agend-agent +110 -0
  306. package/launcher/agend-agent.cjs +4 -0
  307. package/launcher/agend.cjs +4 -0
  308. package/launcher/install-admission.cjs +105 -0
  309. package/launcher/launch.cjs +64 -0
  310. package/launcher/postinstall.cjs +118 -0
  311. package/launcher/run-in-process.cjs +13 -0
  312. package/launcher/runtime-platform.cjs +86 -0
  313. package/launcher/runtime-select.cjs +217 -0
  314. package/package.json +12 -4
  315. package/scripts/preinstall-guard.cjs +40 -21
  316. package/templates/launchd.plist.ejs +2 -2
  317. package/templates/systemd.service.ejs +4 -4
@@ -0,0 +1,237 @@
1
+ // #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
2
+ // wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
3
+ // preview.js makes (mountPreview: the preview origin, sandbox allow-scripts only), started by AgendPreview.start like a
4
+ // card's, only told to fill the panel. It runs on a click, as a card's does:
5
+ // - "Open in panel" on an idle card opens the panel with its Preview button — the panel runs nothing by itself;
6
+ // - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
7
+ // the panel: a moved frame reloads anyway);
8
+ // - a newer version of the block in a later reply is offered, never swapped in, and offered with Preview to click.
9
+ // Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
10
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
+ import { t } from "/assets/app-i18n.js";
12
+ import { Icon } from "/assets/ui-icons.js";
13
+ import "./chat-render.js";
14
+ import "./preview.js";
15
+ import { loadHtmlAttachment } from "./html-attachment.js";
16
+
17
+ const R = () => globalThis.AgendChatRender;
18
+ const P = () => globalThis.AgendPreview;
19
+
20
+ // ── The panel's state, one per page (the chat shows one instance at a time) ──
21
+
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). */
23
+ let state = { open: null, run: 0 };
24
+ const subs = new Set();
25
+ function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
26
+ export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
27
+
28
+ /** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
29
+ export const frameKey = (key) => `panel:${key}`;
30
+ /** Show `spec` in the panel; `run`: start it there (a click that moves a running preview). The block shown before stops. */
31
+ export function openPanel(spec, { run = false } = {}) {
32
+ if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
33
+ set({ open: { ...spec }, run: run ? state.run + 1 : 0 });
34
+ }
35
+ /** Close the panel: its frame stops first. */
36
+ export function closePanel() {
37
+ if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
38
+ if (state.open) set({ open: null, run: 0 });
39
+ }
40
+ /** The card key the panel shows, or null. */
41
+ export const shownKey = () => (state.open ? state.open.key : null);
42
+
43
+ /** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
44
+ export function htmlTitle(code) {
45
+ const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
46
+ return m ? m[1].replace(/\s+/g, " ").trim() : "";
47
+ }
48
+
49
+ /**
50
+ * A newer version of the shown block: the last complete ```html block in an agent reply after the one it came from.
51
+ * When the shown block has a <title>, only a block with the same title counts (another page is not a new version);
52
+ * without one, the newest block does. `msgs` in order; `keyOf` makes a message's key.
53
+ */
54
+ export function newerVersion(open, msgs, keyOf) {
55
+ if (!open || !Array.isArray(msgs)) return null;
56
+ const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
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
+ }
74
+ const title = htmlTitle(open.code);
75
+ for (let i = msgs.length - 1; i > at; i--) {
76
+ const x = msgs[i];
77
+ if (x.role !== "agent") continue;
78
+ const fences = R().htmlFences(x.text);
79
+ for (let n = fences.length - 1; n >= 0; n--) {
80
+ const f = fences[n];
81
+ if (!f.terminated || (title && htmlTitle(f.code) !== title)) continue;
82
+ const msgKey = keyOf(x);
83
+ return { key: `${msgKey}:f${n}`, instance: open.instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts };
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // ── The panel's width: this device's choice (a convenience — the panel works without storage) ──
90
+
91
+ const WIDTH_KEY = "agend_preview_panel_w";
92
+ export const MIN_PANEL = 320, MIN_THREAD = 360, STEP = 32;
93
+ function storedWidth() { try { const v = Number(localStorage.getItem(WIDTH_KEY)); return Number.isFinite(v) && v > 0 ? v : null; } catch { return null; } }
94
+ function storeWidth(w) { try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); } catch { /* this page only */ } }
95
+ /** The width a panel may have in a split `total` px wide: at least MIN_PANEL, leaving the thread MIN_THREAD. */
96
+ export function clampWidth(w, total) {
97
+ const max = Math.max(MIN_PANEL, total - MIN_THREAD - 8);
98
+ return Math.round(Math.min(max, Math.max(MIN_PANEL, w)));
99
+ }
100
+
101
+ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "");
102
+
103
+ /**
104
+ * The panel, inside the chat's split (`split`: that element — the divider sets its width there, through CSSOM).
105
+ * props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
106
+ * reveal(msgKey) (the thread shows that message), download(code).
107
+ */
108
+ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download }) {
109
+ const [st, setSt] = useState(state);
110
+ const [view, setView] = useState("preview");
111
+ const [run, setRun] = useState({ state: "idle", reason: "" });
112
+ const [newer, setNewer] = useState(null);
113
+ const [newerNote, setNewerNote] = useState("");
114
+ const reading = useRef(null); // the newer version whose file is being read (one at a time)
115
+ const holder = useRef(null), divider = useRef(null), panel = useRef(null);
116
+ const open = st.open && st.open.instance === name ? st.open : null;
117
+
118
+ useEffect(() => panelStore.subscribe(setSt), []);
119
+ // This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
120
+ const [, choice] = useState(0);
121
+ useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
122
+ // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
123
+ useEffect(() => () => closePanel(), []);
124
+
125
+ const ui = { state: (s, reason) => setRun({ state: s === "starting" || s === "running" ? s : "idle", reason: reason || "" }) };
126
+ const start = () => {
127
+ if (!open || !holder.current) return;
128
+ setView("preview");
129
+ P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
130
+ };
131
+ const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
132
+ // A new block in the panel: what ran before is gone (openPanel stopped it); a moved run starts here.
133
+ useEffect(() => {
134
+ setRun({ state: "idle", reason: "" });
135
+ setView("preview");
136
+ if (open && st.run) start();
137
+ // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
138
+ if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
139
+ }, [open && open.key, st.run]);
140
+ // A later reply with a newer version of this block: offered, not swapped in.
141
+ useEffect(() => {
142
+ setNewerNote(""); reading.current = null;
143
+ if (!open) { setNewer(null); return undefined; }
144
+ const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
145
+ check();
146
+ return subscribe((instance, kind) => { if (instance === name && kind === "msgs") check(); });
147
+ }, [open && open.key, name]);
148
+
149
+ // The divider: drag, or the arrow keys, Home and End; the width is this device's.
150
+ const widthNow = useRef(MIN_PANEL);
151
+ const applyWidth = (w, save) => {
152
+ const sp = split.current; if (!sp) return;
153
+ const total = sp.clientWidth || 0;
154
+ const v = clampWidth(w, total);
155
+ widthNow.current = v;
156
+ sp.style.setProperty("--pv-w", `${v}px`); // CSSOM: the page's CSP allows no style attribute
157
+ if (divider.current) { divider.current.setAttribute("aria-valuenow", String(v)); divider.current.setAttribute("aria-valuemax", String(clampWidth(Infinity, total))); }
158
+ if (save) storeWidth(v);
159
+ };
160
+ useEffect(() => { if (open) applyWidth(storedWidth() ?? (split.current && split.current.clientWidth || 0) / 2, false); }, [!!open]);
161
+ const width = () => widthNow.current;
162
+ const setWidth = (w) => applyWidth(w, true);
163
+ const onKey = (e) => {
164
+ const k = e.key;
165
+ if (k === "ArrowLeft") setWidth(width() + STEP);
166
+ else if (k === "ArrowRight") setWidth(width() - STEP);
167
+ else if (k === "Home") setWidth(Infinity);
168
+ else if (k === "End") setWidth(0);
169
+ else return;
170
+ e.preventDefault();
171
+ };
172
+ const onDown = (e) => {
173
+ const sp = split.current; if (!sp || e.button !== 0) return;
174
+ e.preventDefault();
175
+ const el = e.currentTarget;
176
+ try { el.setPointerCapture(e.pointerId); } catch { /* the move still works while over the divider */ }
177
+ sp.classList.add("resizing"); // frames take no pointer events meanwhile, or the drag would stop over one
178
+ const right = sp.getBoundingClientRect().right;
179
+ const move = (ev) => applyWidth(right - ev.clientX - 4, false);
180
+ const up = () => { sp.classList.remove("resizing"); storeWidth(widthNow.current); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up); };
181
+ el.addEventListener("pointermove", move);
182
+ el.addEventListener("pointerup", up);
183
+ el.addEventListener("pointercancel", up);
184
+ };
185
+ // Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
186
+ const showNewer = async () => {
187
+ if (!newer || reading.current) return;
188
+ if (newer.code != null) { openPanel(newer); return; }
189
+ const was = open && open.key, mine = reading.current = newer;
190
+ setNewerNote(t("chat.pvLoading"));
191
+ const r = await loadHtmlAttachment(newer.att);
192
+ if (reading.current !== mine) return;
193
+ reading.current = null;
194
+ const now = panelStore.get().open;
195
+ if (!now || now.key !== was) return;
196
+ if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
197
+ openPanel({ ...newer, code: r.code });
198
+ };
199
+ const fileName = open && open.att ? open.att.name : undefined;
200
+ // Esc in the panel closes it (and is not the chat's "stop the reply").
201
+ const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
202
+
203
+ if (!open) return null;
204
+ const going = run.state === "starting" || run.state === "running";
205
+ const a = P().availability();
206
+ const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : a.reason);
207
+ const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
208
+ return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
209
+ aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
210
+ <aside class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
211
+ <div class="pv-panel-head">
212
+ <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
213
+ <div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
214
+ <button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
215
+ <button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
216
+ </div>
217
+ <div class="pv-panel-bar">
218
+ <div class="seg-inline" role="group" aria-label=${t("chat.pvPanel")}>
219
+ <button type="button" class=${`btn btn-sm${view === "preview" ? " on" : ""}`} aria-pressed=${view === "preview" ? "true" : "false"} onClick=${() => setView("preview")}><${Icon} name="eye" size=${14} />${t("chat.pvPage")}</button>
220
+ <button type="button" class=${`btn btn-sm${view === "code" ? " on" : ""}`} aria-pressed=${view === "code" ? "true" : "false"} onClick=${() => setView("code")}><${Icon} name="code" size=${14} />${t("chat.pvCode")}</button>
221
+ </div>
222
+ <span class="grow"></span>
223
+ ${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>
224
+ <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>`
225
+ : 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>`}
226
+ <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>
227
+ </div>
228
+ ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
229
+ <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}
230
+ ${going ? html`<div class="pv-banner">${P().BANNER}</div>` : null}
231
+ ${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
232
+ <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
233
+ <div class="pv-holder" ref=${holder}></div>
234
+ ${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
235
+ </div>
236
+ </aside>`;
237
+ }
@@ -128,8 +128,10 @@
128
128
  /**
129
129
  * Run `html` in `holder` (a node of the card) for `key`. `ui` is told what happened: ui.state(name, reason) with
130
130
  * "starting" | "running" | "stopped" | "unavailable". One preview at a time: any other running one stops first.
131
+ * opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
132
+ * panel instead of by the frame's height messages, which it then ignores.
131
133
  */
132
- function start(key, holder, html, ui) {
134
+ function start(key, holder, html, ui, opts) {
133
135
  var a = availability();
134
136
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
135
137
  if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
@@ -139,14 +141,15 @@
139
141
  var card = {
140
142
  key: key, iframe: iframe, ch: randomCh(), holder: holder, html: html, ui: ui, rendered: false,
141
143
  height: LIMITS.minHeight, lastApplied: -Infinity, appliedInSecond: [], frozen: false, growth: [], pendingFrame: false,
142
- readyTimer: null, watchdogTimer: null,
144
+ readyTimer: null, watchdogTimer: null, fill: !!(opts && opts.fill),
143
145
  };
146
+ if (card.fill) iframe.classList.add("fill");
144
147
  live.set(key, card);
145
148
  card.readyTimer = root.setTimeout(function () {
146
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.");
147
150
  }, LIMITS.readyMs);
148
151
  holder.appendChild(iframe);
149
- setHeight(card, LIMITS.minHeight);
152
+ if (!card.fill) setHeight(card, LIMITS.minHeight);
150
153
  ui.state("starting", "");
151
154
  return true;
152
155
  }
@@ -235,6 +238,7 @@
235
238
  /** Height rules (design §4.2): clamp to [40, 4000]; ≤ 1 per animation frame and ≤ 10 per second; Δ < 2 px ignored;
236
239
  * after 5 consecutive increases within 2 s the height freezes and the frame scrolls. */
237
240
  function requestHeight(card, h) {
241
+ if (card.fill) return; // the panel sizes it (#1481)
238
242
  var want = Math.max(LIMITS.minHeight, Math.min(LIMITS.maxHeight, h));
239
243
  if (Math.abs(want - card.height) < LIMITS.minDelta) return;
240
244
  var t = now();
@@ -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
+ }