@songsid/agend 2.2.0-alpha.2 → 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 (239) 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-permissions.d.ts +33 -0
  50. package/dist/discord-permissions.js +42 -0
  51. package/dist/discord-permissions.js.map +1 -0
  52. package/dist/env-file.d.ts +11 -0
  53. package/dist/env-file.js +27 -0
  54. package/dist/env-file.js.map +1 -0
  55. package/dist/fleet-context.d.ts +5 -0
  56. package/dist/fleet-context.js.map +1 -1
  57. package/dist/fleet-level-config.d.ts +8 -1
  58. package/dist/fleet-level-config.js +31 -0
  59. package/dist/fleet-level-config.js.map +1 -1
  60. package/dist/fleet-manager.d.ts +76 -7
  61. package/dist/fleet-manager.js +599 -127
  62. package/dist/fleet-manager.js.map +1 -1
  63. package/dist/fleet-stop-budget.d.ts +22 -0
  64. package/dist/fleet-stop-budget.js +95 -0
  65. package/dist/fleet-stop-budget.js.map +1 -0
  66. package/dist/kiro-transcript-lane.d.ts +8 -4
  67. package/dist/kiro-transcript-lane.js +66 -44
  68. package/dist/kiro-transcript-lane.js.map +1 -1
  69. package/dist/locale.js +82 -0
  70. package/dist/locale.js.map +1 -1
  71. package/dist/next-steps.d.ts +5 -0
  72. package/dist/next-steps.js +26 -0
  73. package/dist/next-steps.js.map +1 -0
  74. package/dist/outbound-handlers.js +8 -0
  75. package/dist/outbound-handlers.js.map +1 -1
  76. package/dist/outbound-schemas.d.ts +4 -0
  77. package/dist/outbound-schemas.js +6 -1
  78. package/dist/outbound-schemas.js.map +1 -1
  79. package/dist/outbox-open-error.d.ts +7 -0
  80. package/dist/outbox-open-error.js +15 -0
  81. package/dist/outbox-open-error.js.map +1 -0
  82. package/dist/pane-context-cache.d.ts +25 -0
  83. package/dist/pane-context-cache.js +73 -0
  84. package/dist/pane-context-cache.js.map +1 -0
  85. package/dist/paths.d.ts +0 -7
  86. package/dist/paths.js +8 -2
  87. package/dist/paths.js.map +1 -1
  88. package/dist/provider-probe.d.ts +15 -0
  89. package/dist/provider-probe.js +20 -0
  90. package/dist/provider-probe.js.map +1 -1
  91. package/dist/public-link-progress.d.ts +73 -0
  92. package/dist/public-link-progress.js +190 -0
  93. package/dist/public-link-progress.js.map +1 -0
  94. package/dist/public-web-gateway.js +1 -1
  95. package/dist/public-web-gateway.js.map +1 -1
  96. package/dist/public-web-link.d.ts +6 -1
  97. package/dist/public-web-link.js +87 -15
  98. package/dist/public-web-link.js.map +1 -1
  99. package/dist/quickstart-api.d.ts +93 -20
  100. package/dist/quickstart-api.js +194 -62
  101. package/dist/quickstart-api.js.map +1 -1
  102. package/dist/quickstart.d.ts +15 -0
  103. package/dist/quickstart.js +100 -29
  104. package/dist/quickstart.js.map +1 -1
  105. package/dist/reply-buttons-controller.d.ts +98 -0
  106. package/dist/reply-buttons-controller.js +174 -0
  107. package/dist/reply-buttons-controller.js.map +1 -0
  108. package/dist/reply-buttons.d.ts +113 -0
  109. package/dist/reply-buttons.js +208 -0
  110. package/dist/reply-buttons.js.map +1 -0
  111. package/dist/reply-dedup.js +3 -1
  112. package/dist/reply-dedup.js.map +1 -1
  113. package/dist/restart-guard.d.ts +1 -0
  114. package/dist/restart-guard.js +8 -3
  115. package/dist/restart-guard.js.map +1 -1
  116. package/dist/service-activation.d.ts +15 -2
  117. package/dist/service-activation.js +81 -4
  118. package/dist/service-activation.js.map +1 -1
  119. package/dist/service-installer.js +19 -1
  120. package/dist/service-installer.js.map +1 -1
  121. package/dist/settings-api.js +53 -12
  122. package/dist/settings-api.js.map +1 -1
  123. package/dist/settings-authority.d.ts +11 -0
  124. package/dist/settings-authority.js +128 -0
  125. package/dist/settings-authority.js.map +1 -0
  126. package/dist/settings-change.d.ts +5 -0
  127. package/dist/settings-change.js +4 -3
  128. package/dist/settings-change.js.map +1 -1
  129. package/dist/settings-confirmation.d.ts +3 -0
  130. package/dist/settings-confirmation.js +7 -1
  131. package/dist/settings-confirmation.js.map +1 -1
  132. package/dist/settings-effect.js +19 -7
  133. package/dist/settings-effect.js.map +1 -1
  134. package/dist/settings-http-confirmation.js +1 -1
  135. package/dist/settings-http-confirmation.js.map +1 -1
  136. package/dist/settings-redaction.d.ts +18 -0
  137. package/dist/settings-redaction.js +58 -0
  138. package/dist/settings-redaction.js.map +1 -0
  139. package/dist/setup-form.d.ts +9 -1
  140. package/dist/setup-form.js +25 -1
  141. package/dist/setup-form.js.map +1 -1
  142. package/dist/setup-host.js +13 -3
  143. package/dist/setup-host.js.map +1 -1
  144. package/dist/setup-wizard.js +26 -19
  145. package/dist/setup-wizard.js.map +1 -1
  146. package/dist/systemd-runtime.d.ts +15 -0
  147. package/dist/systemd-runtime.js +41 -0
  148. package/dist/systemd-runtime.js.map +1 -0
  149. package/dist/tmux-control.d.ts +22 -0
  150. package/dist/tmux-control.js +82 -17
  151. package/dist/tmux-control.js.map +1 -1
  152. package/dist/token-env-name.d.ts +37 -0
  153. package/dist/token-env-name.js +93 -0
  154. package/dist/token-env-name.js.map +1 -0
  155. package/dist/tool-permissions.js +5 -3
  156. package/dist/tool-permissions.js.map +1 -1
  157. package/dist/topic-commands.d.ts +7 -16
  158. package/dist/topic-commands.js +45 -83
  159. package/dist/topic-commands.js.map +1 -1
  160. package/dist/transcript-monitor.js +3 -0
  161. package/dist/transcript-monitor.js.map +1 -1
  162. package/dist/transcript-sources.d.ts +68 -0
  163. package/dist/transcript-sources.js +336 -29
  164. package/dist/transcript-sources.js.map +1 -1
  165. package/dist/transcript-turns.d.ts +39 -0
  166. package/dist/transcript-turns.js +58 -0
  167. package/dist/transcript-turns.js.map +1 -0
  168. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  169. package/dist/tunnel/cloudflared-install.js +15 -1
  170. package/dist/tunnel/cloudflared-install.js.map +1 -1
  171. package/dist/turn-reply-guard.d.ts +3 -0
  172. package/dist/turn-reply-guard.js.map +1 -1
  173. package/dist/ui/chat-render.js +32 -6
  174. package/dist/ui/chat-store.js +21 -1
  175. package/dist/ui/chat-strings.js +8 -0
  176. package/dist/ui/chat-thread.js +109 -52
  177. package/dist/ui/first-run.js +64 -0
  178. package/dist/ui/html-attachment.js +26 -0
  179. package/dist/ui/panel-chat.js +13 -5
  180. package/dist/ui/panel-fleet.js +5 -5
  181. package/dist/ui/panel-settings.js +18 -8
  182. package/dist/ui/preview-panel.js +38 -3
  183. package/dist/ui/settings-apply.js +3 -1
  184. package/dist/ui/settings-dialogs.js +95 -25
  185. package/dist/ui/settings-discord.js +130 -0
  186. package/dist/ui/settings-model.js +21 -0
  187. package/dist/ui/settings-strings.js +46 -6
  188. package/dist/ui/settings-token.js +54 -0
  189. package/dist/ui/settings-wizard.js +63 -29
  190. package/dist/ui/shared/app-shell.js +19 -33
  191. package/dist/ui/shared/app-store.js +5 -3
  192. package/dist/ui/shared/app-stream.js +2 -1
  193. package/dist/ui/shared/app.css +34 -2
  194. package/dist/ui/shared/app.js +2 -1
  195. package/dist/ui/shared/instance-nav.js +310 -0
  196. package/dist/ui/shared/panel-view.js +12 -190
  197. package/dist/ui/shared/view-roster-store.js +5 -0
  198. package/dist/ui/shared/view-strings.js +12 -0
  199. package/dist/update-dispatch.d.ts +1 -1
  200. package/dist/update-dispatch.js +1 -1
  201. package/dist/update-install.d.ts +15 -3
  202. package/dist/update-install.js +41 -13
  203. package/dist/update-install.js.map +1 -1
  204. package/dist/update-launch.d.ts +22 -0
  205. package/dist/update-launch.js +106 -0
  206. package/dist/update-launch.js.map +1 -0
  207. package/dist/update-marker.js +3 -8
  208. package/dist/update-marker.js.map +1 -1
  209. package/dist/usage/format-rich.js +7 -1
  210. package/dist/usage/format-rich.js.map +1 -1
  211. package/dist/usage/i18n-keys.d.ts +1 -1
  212. package/dist/usage/i18n-keys.js +1 -1
  213. package/dist/usage/i18n-keys.js.map +1 -1
  214. package/dist/usage/providers.d.ts +4 -0
  215. package/dist/usage/providers.js +2 -1
  216. package/dist/usage/providers.js.map +1 -1
  217. package/dist/usage/usage-api.js +48 -2
  218. package/dist/usage/usage-api.js.map +1 -1
  219. package/dist/view-api.d.ts +40 -0
  220. package/dist/view-api.js +28 -10
  221. package/dist/view-api.js.map +1 -1
  222. package/dist/web-api.d.ts +8 -0
  223. package/dist/web-api.js +50 -16
  224. package/dist/web-api.js.map +1 -1
  225. package/dist/web-auth.d.ts +8 -0
  226. package/dist/web-auth.js +34 -8
  227. package/dist/web-auth.js.map +1 -1
  228. package/dist/web-chat-history.d.ts +21 -0
  229. package/dist/web-chat-history.js +44 -0
  230. package/dist/web-chat-history.js.map +1 -1
  231. package/dist/web-host-guard.d.ts +18 -2
  232. package/dist/web-host-guard.js +30 -5
  233. package/dist/web-host-guard.js.map +1 -1
  234. package/dist/web-session.d.ts +7 -1
  235. package/dist/web-session.js +23 -2
  236. package/dist/web-session.js.map +1 -1
  237. package/launcher/postinstall.cjs +8 -4
  238. package/package.json +1 -1
  239. package/templates/systemd.service.ejs +3 -3
@@ -8,6 +8,7 @@
8
8
  // Unrelated updates (a new message, another message's tick) stop nothing (#1306 §6.2, #1408 §4).
9
9
  import "./chat-render.js";
10
10
  import "./preview.js";
11
+ import { loadHtmlAttachment } from "./html-attachment.js";
11
12
 
12
13
  const R = () => globalThis.AgendChatRender;
13
14
  const P = () => globalThis.AgendPreview;
@@ -44,7 +45,23 @@ export function createThread(list, scroller, opts) {
44
45
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
45
46
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
46
47
  const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
47
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments)}</div>${tools}</div>`;
48
+ const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
49
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, x.role === "agent" ? { htmlCards: true } : undefined)}${buttons}</div>${tools}</div>`;
50
+ }
51
+ /**
52
+ * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
53
+ * nothing else. Ended (chosen / expired) or a click in flight: every button off; the chosen one marked, and who.
54
+ */
55
+ function replyButtonsHtml(b) {
56
+ if (!b || typeof b.id !== "string" || !Array.isArray(b.labels) || !b.labels.length) return "";
57
+ const busy = !!(opts.replyButtonBusy && opts.replyButtonBusy(b.id));
58
+ const off = b.state !== "open" || busy;
59
+ const items = b.labels.map((label, i) => {
60
+ const chosen = b.state === "chosen" && b.chosen === i;
61
+ return `<button type="button" class="btn btn-sm rb-btn${chosen ? " chosen" : ""}" data-act="replyButton" data-arg="${escAttr(`${b.id}:${i}`)}"${off ? " disabled" : ""}>${escAttr(chosen ? `✓ ${label}` : label)}</button>`;
62
+ }).join("");
63
+ const note = b.state === "chosen" ? trf("chat.rbChosen", b.by || "") : b.state === "expired" ? tr("chat.rbExpired") : "";
64
+ return `<div class="rb-row" role="group" aria-label="${escAttr(tr("chat.rbGroup"))}">${items}${note ? `<span class="rb-note">${escAttr(note)}</span>` : ""}</div>`;
48
65
  }
49
66
  function msgNode(html, x, stoppedKeys) {
50
67
  const tpl = list.ownerDocument.createElement("template");
@@ -76,72 +93,108 @@ export function createThread(list, scroller, opts) {
76
93
  }
77
94
  }
78
95
 
79
- // HTML cards (#1306): each ```html fence of a server-marked agent message gets a card under its code block. Building
80
- // a card makes no frame; Preview does (AgendPreview.start → mountPreview).
96
+ // HTML cards (#1306): each ```html fence of a server-marked agent message gets a card under its code block, and each
97
+ // .html/.htm file it attached gets one under its files (§6.1, Q4). Building a card makes no frame and fetches
98
+ // nothing; Preview does (AgendPreview.start → mountPreview), after reading an attachment's HTML on that click.
81
99
  function decorateHtmlCards(node, x, stoppedKeys) {
82
100
  const doc = list.ownerDocument;
83
101
  const fences = R().htmlFences(x.text);
84
102
  for (const ph of node.querySelectorAll(".html-card[data-card]")) {
85
103
  const n = Number(ph.dataset.card), fence = fences[n];
86
104
  if (!fence) continue;
87
- const key = `${msgKey(x)}:f${n}`;
88
105
  ph.textContent = "";
89
106
  if (ph.classList.contains("truncated") || !fence.terminated) {
90
107
  const note = doc.createElement("div"); note.className = "pv-note"; note.textContent = tr("chat.pvTruncated"); ph.append(note);
91
108
  continue;
92
109
  }
93
- const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
94
- const head = el("div", "pv-head");
95
- const label = el("span", "pv-label", tr("chat.pvHtml"));
96
- const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
97
- const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
98
- const dl = el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload")); dl.type = "button";
99
- const panel = opts.panel || null;
100
- const toPanel = panel ? el("button", "btn btn-sm btn-ghost pv-open", tr("chat.pvOpenPanel")) : null;
101
- if (toPanel) toPanel.type = "button";
102
- const menu = el("details", "pv-menu");
103
- const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
104
- const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
105
- const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
106
- menu.append(sum, optB, neverB);
107
- head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), dl, menu);
108
- const note = el("div", "pv-note");
109
- const banner = el("div", "pv-banner", P().BANNER); banner.hidden = true;
110
- const holder = el("div", "pv-holder");
111
- ph.append(head, note, banner, holder);
112
- let state = "idle";
113
- const refresh = (reason) => {
114
- const a = P().availability();
115
- const going = state === "starting" || state === "running";
116
- const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
117
- run.hidden = !a.ok || going || inPanel;
118
- if (toPanel) toPanel.hidden = inPanel;
119
- stopB.hidden = !going;
120
- banner.hidden = !going;
121
- note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
122
- optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
123
- neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
124
- };
125
- const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
126
- run.onclick = () => P().start(key, holder, fence.code, ui);
127
- stopB.onclick = () => P().stop(key, "stopped", "");
128
- // A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
129
- // panel with its own Preview to click.
130
- if (toPanel) toPanel.onclick = () => {
131
- const moving = P().running(key);
132
- if (moving) P().stop(key, "stopped", "");
133
- panel.open({ key, instance: x.instance, msgKey: msgKey(x), n, code: fence.code, sender: x.sender, ts: x.ts }, { run: moving });
134
- };
135
- dl.onclick = () => opts.download(fence.code);
136
- optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
137
- neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
138
- cardRefresh.set(ph, refresh);
139
- refresh(stoppedKeys.includes(key) ? tr("chat.pvChanged") : null);
110
+ buildCard(ph, x, `${msgKey(x)}:f${n}`, { n, code: fence.code }, stoppedKeys);
111
+ }
112
+ const atts = Array.isArray(x.attachments) ? x.attachments : [];
113
+ for (const ph of node.querySelectorAll(".html-card[data-att]")) {
114
+ const att = atts.find(a => a && a.id === ph.dataset.att);
115
+ if (!att || !R().isHtmlAttachment(att)) continue;
116
+ ph.textContent = "";
117
+ buildCard(ph, x, `${msgKey(x)}:a${att.id}`, { att: { id: att.id, name: att.name, size: att.size } }, stoppedKeys);
140
118
  }
141
119
  }
120
+ /** Why an attachment's HTML could not be read, for the card's note. */
121
+ const attReason = (reason) => tr(reason === "over" ? "chat.pvAttOver" : reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed");
122
+ /** One card: `source` is { n, code } for a fence or { att } for an attachment (its HTML read on the first click). */
123
+ function buildCard(ph, x, key, source, stoppedKeys) {
124
+ const doc = list.ownerDocument;
125
+ const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
126
+ const att = source.att || null;
127
+ let code = att ? null : source.code;
128
+ const head = el("div", "pv-head");
129
+ const label = el("span", "pv-label", att ? `${tr("chat.pvHtml")} · ${att.name}` : tr("chat.pvHtml"));
130
+ const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
131
+ const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
132
+ // An attachment already has its download link (the file itself) right above the card.
133
+ const dl = att ? null : el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload"));
134
+ if (dl) dl.type = "button";
135
+ const panel = opts.panel || null;
136
+ const toPanel = panel ? el("button", "btn btn-sm btn-ghost pv-open", tr("chat.pvOpenPanel")) : null;
137
+ if (toPanel) toPanel.type = "button";
138
+ const menu = el("details", "pv-menu");
139
+ const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
140
+ const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
141
+ const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
142
+ menu.append(sum, optB, neverB);
143
+ head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
144
+ const note = el("div", "pv-note");
145
+ const banner = el("div", "pv-banner", P().BANNER); banner.hidden = true;
146
+ const holder = el("div", "pv-holder");
147
+ ph.append(head, note, banner, holder);
148
+ let state = "idle";
149
+ // Reading an attachment: one read per card at a time (a second click while it reads does nothing), and a read
150
+ // whose card was stopped, replaced or left meanwhile is dropped (`reading` moved on, or the card left the page).
151
+ let reading = 0, reads = 0;
152
+ const refresh = (reason) => {
153
+ const a = P().availability();
154
+ const going = state === "starting" || state === "running";
155
+ const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
156
+ run.hidden = !a.ok || going || inPanel || !!reading;
157
+ if (toPanel) toPanel.hidden = inPanel || !!reading;
158
+ stopB.hidden = !going && !reading;
159
+ banner.hidden = !going;
160
+ note.textContent = reason != null ? reason : reading ? tr("chat.pvLoading") : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
161
+ optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
162
+ neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
163
+ };
164
+ /** The HTML, then `then(code)`: at once for a fence or an attachment already read; otherwise read it first. */
165
+ const withCode = async (then) => {
166
+ if (code != null) { then(code); return; }
167
+ if (reading) return;
168
+ const mine = reading = ++reads;
169
+ refresh();
170
+ const r = await loadHtmlAttachment(att);
171
+ if (reading !== mine || !ph.isConnected) return;
172
+ reading = 0;
173
+ if (!r.ok) { refresh(attReason(r.reason)); return; }
174
+ code = r.code;
175
+ refresh();
176
+ then(code);
177
+ };
178
+ const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
179
+ // Nothing is read for Preview on a device that does not allow previews: the button is not even shown there.
180
+ run.onclick = () => { if (P().availability().ok) withCode((c) => P().start(key, holder, c, ui)); };
181
+ stopB.onclick = () => { if (reading) { reading = 0; refresh(); } else P().stop(key, "stopped", ""); };
182
+ // A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
183
+ // panel with its own Preview to click.
184
+ if (toPanel) toPanel.onclick = () => {
185
+ const moving = P().running(key);
186
+ if (moving) P().stop(key, "stopped", "");
187
+ withCode((c) => panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: moving }));
188
+ };
189
+ if (dl) dl.onclick = () => opts.download(code);
190
+ optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
191
+ neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
192
+ cardRefresh.set(ph, refresh);
193
+ refresh(stoppedKeys.includes(key) ? tr("chat.pvChanged") : null);
194
+ }
142
195
  /** Re-read this device's preview choice on every card in this thread. */
143
196
  function refreshCards() {
144
- for (const node of list.querySelectorAll(".html-card[data-card]")) { const f = cardRefresh.get(node); if (f) f(); }
197
+ for (const node of list.querySelectorAll(".html-card[data-card], .html-card[data-att]")) { const f = cardRefresh.get(node); if (f) f(); }
145
198
  }
146
199
 
147
200
  const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
@@ -233,6 +286,10 @@ export function createThread(list, scroller, opts) {
233
286
  const folded = box.classList.toggle("folded");
234
287
  el.textContent = folded ? trf("chat.showAll", el.dataset.lines) : tr("chat.showLess");
235
288
  } else if (act === "toggleWrap") opts.toggleWrap();
289
+ else if (act === "replyButton") {
290
+ const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
291
+ if (m && opts.clickReplyButton) opts.clickReplyButton(m[1], Number(m[2]));
292
+ }
236
293
  }
237
294
 
238
295
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
@@ -0,0 +1,64 @@
1
+ // #1519 P7 (docs/design/ux-onboarding-walkthrough.md §5.1): the first-run card. A fleet with no chat connection (a
2
+ // web-only fleet) opens Chat and Settings with it: "Connect a chat app" opens the setup wizard (in Settings, where its
3
+ // progress is shown), "Use the web only" hides it in this browser. It stays until a connection exists or it is dismissed.
4
+ import { html } from "/assets/app-html.js";
5
+ import { register, t } from "/assets/app-i18n.js";
6
+ import { appStore, createStore, useStore } from "/assets/app-store.js";
7
+ import { navigate } from "/assets/app-nav.js";
8
+ import { settingsPath } from "/assets/app-route.js";
9
+ import { Icon } from "/assets/ui-icons.js";
10
+
11
+ register("firstRun", {
12
+ en: {
13
+ title: "Connect a chat app",
14
+ body: "AgEnD works in this browser on its own. To talk to your agents from Telegram or Discord too, add a bot: the setup wizard checks its token and connects it to an agent.",
15
+ connect: "Connect a chat app", webOnly: "Use the web only",
16
+ },
17
+ "zh-TW": {
18
+ title: "連接聊天應用程式",
19
+ body: "AgEnD 只用這個瀏覽器也能運作。想同時從 Telegram 或 Discord 跟你的 agent 對話,就加一個機器人:設定精靈會檢查它的 token,並把它接到一個 agent。",
20
+ connect: "連接聊天應用程式", webOnly: "只用網頁",
21
+ },
22
+ });
23
+
24
+ export const DISMISS_KEY = "agend_firstrun_dismissed";
25
+ function readDismissed() { try { return localStorage.getItem(DISMISS_KEY) === "1"; } catch { return false; } }
26
+ // `dismissed`: this page's own answer (it holds even when the browser keeps no storage); a stored one is read each time.
27
+ const local = createStore({ dismissed: false, wizard: false });
28
+
29
+ export function dismissFirstRun() {
30
+ try { localStorage.setItem(DISMISS_KEY, "1"); } catch { /* this page only */ }
31
+ local.set({ dismissed: true });
32
+ }
33
+
34
+ /** For the tests: a fresh page's state. */
35
+ export function resetFirstRun() { local.set({ dismissed: false, wizard: false }); }
36
+
37
+ /** Settings asks once, when it has its data: was it opened by "Connect a chat app" (open the wizard)? */
38
+ export function takeWizardRequest() {
39
+ if (!local.get().wizard) return false;
40
+ local.set({ wizard: false });
41
+ return true;
42
+ }
43
+
44
+ /** Shown on a known web-only fleet (no connection) until dismissed. */
45
+ export function useFirstRun() {
46
+ const app = useStore(appStore);
47
+ const { dismissed } = useStore(local);
48
+ return app.ready && app.connections === 0 && !dismissed && !readDismissed();
49
+ }
50
+
51
+ /** onConnect: Settings opens its wizard in place; elsewhere the card goes to Settings, which opens it. */
52
+ export function FirstRunCard({ onConnect }) {
53
+ const shown = useFirstRun();
54
+ if (!shown) return null;
55
+ const connect = onConnect || (() => { local.set({ wizard: true }); navigate(settingsPath("bots")); });
56
+ return html`<section class="card first-run" aria-labelledby="first-run-title">
57
+ <h3 id="first-run-title"><${Icon} name="plug" size=${14} /> ${t("firstRun.title")}</h3>
58
+ <p>${t("firstRun.body")}</p>
59
+ <div class="dlg-inline-actions">
60
+ <button type="button" class="btn btn-primary" onClick=${connect}>${t("firstRun.connect")}</button>
61
+ <button type="button" class="btn btn-ghost" onClick=${dismissFirstRun}>${t("firstRun.webOnly")}</button>
62
+ </div>
63
+ </section>`;
64
+ }
@@ -0,0 +1,26 @@
1
+ // #1306 §6.1 (Q4): an agent's .html / .htm attachment gets the same preview card as a ```html block. Its HTML is read
2
+ // from /ui/file/<id> — same origin, this session's cookie — only on a click (Preview, Open in panel), never to show a
3
+ // card. What runs is then handed to the preview frame exactly like a block's (AgendPreview.start): the file URL itself
4
+ // is never opened or framed on the dashboard's origin.
5
+
6
+ /** The same cap as a block's (preview.js LIMITS.maxBytes): UTF-8 bytes of the HTML. */
7
+ export const HTML_ATTACHMENT_MAX = 1024 * 1024;
8
+
9
+ /**
10
+ * Read an attachment's HTML: { ok: true, code } or { ok: false, reason } with reason "over" (the file is over the
11
+ * cap: its listed size is checked before anything is fetched, the bytes again after), "gone" (the fleet no longer
12
+ * serves that id — the file changed, or the fleet restarted), or "failed".
13
+ */
14
+ export async function loadHtmlAttachment(att, fetchImpl = globalThis.fetch) {
15
+ if (!att || typeof att.id !== "string" || !/^[0-9a-f]{32}$/.test(att.id)) return { ok: false, reason: "failed" };
16
+ if (typeof att.size === "number" && att.size > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
17
+ let res;
18
+ try { res = await fetchImpl(`/ui/file/${att.id}`, { credentials: "same-origin", cache: "no-store" }); }
19
+ catch { return { ok: false, reason: "failed" }; }
20
+ if (!res || !res.ok) return { ok: false, reason: res && res.status === 404 ? "gone" : "failed" };
21
+ let bytes;
22
+ try { bytes = await res.arrayBuffer(); } catch { return { ok: false, reason: "failed" }; }
23
+ if (bytes.byteLength > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
24
+ // UTF-8, as a block's text is; a BOM is dropped.
25
+ return { ok: true, code: new TextDecoder("utf-8").decode(bytes) };
26
+ }
@@ -10,7 +10,7 @@ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-
10
10
  import { t } from "/assets/app-i18n.js";
11
11
  import { appStore, useStore } from "/assets/app-store.js";
12
12
  import { useLease } from "/assets/app-ctx.js";
13
- import { PanelHeader, onPanelKey, setTitle, addFooterItem, statusClass, statusLabel, openDrawer } from "/assets/app-shell.js";
13
+ import { PanelHeader, onPanelKey, setTitle, addFooterItem, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
14
14
  import { navigate } from "/assets/app-nav.js";
15
15
  import { Menu } from "/assets/ui-menu.js";
16
16
  import { Dialog } from "/assets/ui-dialog.js";
@@ -25,6 +25,7 @@ import { createThread, msgKey } from "./chat-thread.js";
25
25
  import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
26
26
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
27
27
  import { installTour, refreshTourSpot, startTour } from "./chat-tour.js";
28
+ import { FirstRunCard } from "./first-run.js";
28
29
 
29
30
  const R = () => globalThis.AgendChatRender;
30
31
  const P = () => globalThis.AgendPreview;
@@ -99,9 +100,9 @@ async function copyText(text) {
99
100
  return ok;
100
101
  }
101
102
  /** The HTML as a file: a blob of the source as application/octet-stream, saved as reply.html — never opened. */
102
- function downloadHtml(code) {
103
+ function downloadHtml(code, name) {
103
104
  const url = URL.createObjectURL(new Blob([code], { type: "application/octet-stream" }));
104
- const a = document.createElement("a"); a.href = url; a.download = "reply.html"; a.className = "offscreen";
105
+ const a = document.createElement("a"); a.href = url; a.download = typeof name === "string" && name.trim() ? name.trim() : "reply.html"; a.className = "offscreen";
105
106
  document.body.appendChild(a); a.click(); a.remove();
106
107
  URL.revokeObjectURL(url);
107
108
  }
@@ -127,9 +128,14 @@ export function ChatPanel({ route, navKey }) {
127
128
  }, [name, !!inst]);
128
129
  useEffect(() => { setTitle(name || t("app.chat")); }, [name, navKey]);
129
130
  if (!name) {
131
+ // #1519 P7: a web-only fleet starts here with the first-run card; with no agent at all, the way to make one.
132
+ const none = app.ready && !app.instances.length;
130
133
  return html`<div class="panel p-chat"><${PanelHeader} title=${t("app.chat")} />
131
- <div class="panel-body center"><${Empty} icon="chat" title=${t("chat.pick")} hint=${t("chat.pickHint")}
132
- action=${html`<button type="button" class="btn only-narrow" onClick=${openDrawer}>${t("chat.backToList")}</button>`} /></div></div>`;
134
+ <div class="panel-body center"><div class="first-run-stack"><${FirstRunCard} />
135
+ ${none ? html`<${Empty} icon="bot" title=${t("app.noInstances")} hint=${t("app.noInstancesHint")}
136
+ action=${html`<button type="button" class="btn btn-primary" onClick=${requestNewInstance}><${Icon} name="plus" size=${16} />${t("app.newInstance")}</button>`} />`
137
+ : html`<${Empty} icon="chat" title=${t("chat.pick")} hint=${t("chat.pickHint")}
138
+ action=${html`<button type="button" class="btn only-narrow" onClick=${openDrawer}>${t("chat.backToList")}</button>`} />`}</div></div></div>`;
133
139
  }
134
140
  if (!app.ready) return html`<div class="panel p-chat"><${PanelHeader} title=${name} /><div class="panel-body"><${Skeleton} lines=${5} /></div></div>`;
135
141
  if (!inst) return html`<${NotFound} name=${name} />`;
@@ -221,6 +227,8 @@ function Thread({ name, th }) {
221
227
  const thread = createThread(list.current, scroller.current, {
222
228
  t, tf: t, isUser: (x) => store.isUser(x, name), onJump: setJump, onEmpty: setEmpty,
223
229
  setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
230
+ clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
231
+ replyButtonBusy: (id) => store.state.rbBusy.has(id),
224
232
  panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
225
233
  });
226
234
  th.current = thread;
@@ -36,8 +36,8 @@ register("fleet", {
36
36
  channel: "Channel", type: "Type", tokenEnv: "Bot token env", groupId: "Group ID", show: "Show", hide: "Hide", access: "Access control", mode: "Mode", locked: "Locked", open: "Open",
37
37
  allowedUsers: "Allowed users", allowedUsersNote: "Comma-separated user IDs", defaults: "Defaults", backend: "Backend", roots: "Project roots", addRoot: "Add root", removeRoot: "Remove",
38
38
  saveNote: "Saving rewrites fleet.yaml (its comments are lost).",
39
- newInstance: "New instance", directory: "Directory", directoryHint: "Optional — a workspace is created if empty", topic: "Topic name", topicHint: "From the directory name",
40
- topicRequired: "Topic name is required when Directory is empty", description: "Description", descriptionHint: "What this instance does", model: "Model", modelHint: "e.g. sonnet, opus, gpt-5",
39
+ newInstance: "New instance", directory: "Directory", directoryHint: "Optional — a workspace is created if empty", topic: "Name", topicHint: "From the directory name",
40
+ topicRequired: "Name is required when Directory is empty", description: "Description", descriptionHint: "What this instance does", model: "Model", modelHint: "e.g. sonnet, opus, gpt-5",
41
41
  branch: "Branch (git worktree)", branchHint: "e.g. feature-x", tags: "Tags", tagsHint: "comma-separated, e.g. dev, review", systemPrompt: "System prompt", systemPromptHint: "Custom instructions for this instance",
42
42
  fleetDefault: "Use the fleet default", notInstalled: "(not installed)", deprecated: "(deprecated)", instanceCreated: "Instance created",
43
43
  },
@@ -56,8 +56,8 @@ register("fleet", {
56
56
  channel: "頻道", type: "類型", tokenEnv: "Bot token 環境變數", groupId: "Group ID", show: "顯示", hide: "隱藏", access: "存取控制", mode: "模式", locked: "鎖定", open: "開放",
57
57
  allowedUsers: "允許的使用者", allowedUsersNote: "以逗號分隔的使用者 ID", defaults: "預設值", backend: "Backend", roots: "專案根目錄", addRoot: "新增根目錄", removeRoot: "移除",
58
58
  saveNote: "儲存會改寫 fleet.yaml(其中的註解會遺失)。",
59
- newInstance: "新增 instance", directory: "目錄", directoryHint: "可留空——留空會自動建立工作區", topic: "Topic 名稱", topicHint: "預設取自目錄名稱",
60
- topicRequired: "目錄留空時必須填寫 Topic 名稱", description: "說明", descriptionHint: "這個 instance 做什麼", model: "模型", modelHint: "例如 sonnet、opus、gpt-5",
59
+ newInstance: "新增 instance", directory: "目錄", directoryHint: "可留空——留空會自動建立工作區", topic: "名稱", topicHint: "預設取自目錄名稱",
60
+ topicRequired: "目錄留空時必須填寫名稱", description: "說明", descriptionHint: "這個 instance 做什麼", model: "模型", modelHint: "例如 sonnet、opus、gpt-5",
61
61
  branch: "分支(git worktree)", branchHint: "例如 feature-x", tags: "標籤", tagsHint: "以逗號分隔,例如 dev, review", systemPrompt: "System prompt", systemPromptHint: "這個 instance 的自訂指示",
62
62
  fleetDefault: "使用 fleet 預設", notInstalled: "(未安裝)", deprecated: "(已淘汰)", instanceCreated: "Instance 已建立",
63
63
  },
@@ -359,7 +359,7 @@ function CreateTeam({ onClose, onDone }) {
359
359
  </${FormDialog}>`;
360
360
  }
361
361
 
362
- /** "New instance": the sidebar's ✎ opens it from anywhere (#1408 step 3 merges it with Settings' create flow). */
362
+ /** "New instance": the sidebar's + opens it from anywhere (#1408 step 3 merges it with Settings' create flow). */
363
363
  export function CreateInstanceDialog({ onClose }) {
364
364
  const lease = useLease("create-instance");
365
365
  const [backends, setBackends] = useState(null);
@@ -25,7 +25,7 @@ import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
25
25
  import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
26
26
  import {
27
27
  ACCESS_MODES, AGENT_MODES, DEFAULT_SCHEMA, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
28
- changedFields, channelId, channelsOf, chLabel, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
28
+ changedFields, channelId, channelsOf, chLabel, connectionState, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
29
29
  hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, shortName, STATUS_ORDER, toYaml,
30
30
  visibilityDefault, worstImpact,
31
31
  } from "./settings-model.js";
@@ -35,6 +35,7 @@ import {
35
35
  } from "./settings-dialogs.js";
36
36
  import { SetupWizard } from "./settings-wizard.js";
37
37
  import { CreateInstanceDialog } from "./panel-fleet.js";
38
+ import { FirstRunCard, takeWizardRequest } from "./first-run.js";
38
39
 
39
40
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
40
41
  const ICONS = { agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
@@ -197,6 +198,8 @@ export function SettingsPanel({ route, navKey }) {
197
198
  };
198
199
 
199
200
  const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current()) : null;
201
+ // #1519 P7: "Connect a chat app" on the first-run card elsewhere came here for the wizard: it opens once the data is in.
202
+ useEffect(() => { if (ctx && takeWizardRequest()) setDialog({ kind: "wizard", key: navKey }); }, [!!ctx, navKey]);
200
203
  const sectionBody = !data ? html`<${Skeleton} lines=${6} />`
201
204
  : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
202
205
  : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
@@ -220,6 +223,7 @@ export function SettingsPanel({ route, navKey }) {
220
223
  <input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
221
224
  ${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
222
225
  <${PendingChanges} inline=${true} />
226
+ ${ctx && (section === "agents" || section === "bots") ? html`<${FirstRunCard} onConnect=${() => setDialog({ kind: "wizard", key: navKey })} />` : null}
223
227
  ${sectionBody}
224
228
  </div></div>
225
229
  ${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
@@ -378,7 +382,12 @@ function Bots({ ctx, search, openDialog }) {
378
382
  return html`${head}
379
383
  ${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
380
384
  const users = (ch.access && ch.access.allowed_users) || [];
381
- const token = (ctx.connections.find((c, j) => channelId(c, j) === channelId(ch, i)) || {}).token_present;
385
+ const meta = ctx.connections.find((c, j) => channelId(c, j) === channelId(ch, i)) || {};
386
+ const token = meta.token_present;
387
+ // #1519 P6: this connection's own state (not the fleet's "Connected") — a stopped one says so; a known problem says
388
+ // what to do (P3: Message Content intent; a rejected token).
389
+ const state = connectionState(meta, ctx.fleetUp);
390
+ const stateText = tn(state.key, state.bot);
382
391
  // #1408 step 5: one line on a desktop — who it is, its facts in one clipped line (all of them in its tooltip),
383
392
  // then the cluster; on a phone the facts go under it. It no longer wraps fact by fact.
384
393
  const facts = [ch.bot_token_env || tn("noTokenEnv"),
@@ -386,13 +395,13 @@ function Bots({ ctx, search, openDialog }) {
386
395
  tn("accessTag", (ch.access && ch.access.mode) || "locked"),
387
396
  users.length ? `${users.slice(0, 3).join(", ")}${users.length > 3 ? ` +${users.length - 3}` : ""}` : tn("noAllowedUsers")].filter(Boolean).join(" · ");
388
397
  return html`<div key=${channelId(ch, i)} class="s-row s-conn">
389
- <span class="s-ident"><span class=${`dot ${ctx.fleetUp ? "ok" : "bad"}`} title=${ctx.fleetUp ? tn("connected") : tn("problem")} aria-hidden="true"></span>
398
+ <span class="s-ident"><span class=${`dot ${state.cls}`} title=${stateText} aria-hidden="true"></span>
390
399
  <span class="s-name">${ch.type === "telegram" ? "Telegram" : "Discord"}</span>
391
400
  <span class=${`tag${i === 0 ? "" : " persona"}`}>${ch.id || chLabel(i)}</span></span>
392
401
  <span class="s-facts" title=${facts}>${facts}</span>
393
402
  <span class="s-actions">
394
403
  <span class=${`tag${token ? "" : " warn"}`}>${token ? tn("tokenConfigured") : tn("tokenMissing")}</span>
395
- <span class=${`s-state ${ctx.fleetUp ? "ok" : "bad"}`}>${ctx.fleetUp ? tn("connected") : tn("problem")}</span>
404
+ <span class=${`s-state ${state.cls}`} title=${stateText}>${stateText}</span>
396
405
  <button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
397
406
  </div>`;
398
407
  })}</div>`}
@@ -671,7 +680,7 @@ function Developer({ ctx }) {
671
680
  ${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
672
681
  <button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
673
682
  <button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
674
- <button type="button" class="btn btn-sm" title="fleet.yaml" onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">fleet.yaml</span></button>
683
+ <button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
675
684
  </div>
676
685
  ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
677
686
  <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
@@ -699,7 +708,8 @@ function OperationCard({ op, schema }) {
699
708
  const phaseBusy = ["writing", "posting", "watching"].includes(op.phase);
700
709
  const job = op.job;
701
710
  const rows = (job && job.targets) || [];
702
- const fleetRestarting = job && job.status === "running" && rows.some((r) => r.target === "fleet" && r.status === "running");
711
+ // A fleet row of kind "hot" is connections starting without a restart (#1519 P6), not AgEnD restarting.
712
+ const fleetRestarting = job && job.status === "running" && rows.some((r) => r.target === "fleet" && r.status === "running" && r.kind !== "hot");
703
713
  const needsRestart = rows.some((r) => r.status === "restart-required");
704
714
  const failedRows = rows.some((r) => r.status === "failed");
705
715
  const mismatch = schema && schema.fleet_signature_mismatch;
@@ -732,11 +742,11 @@ function OperationCard({ op, schema }) {
732
742
  ${op.steps.length ? html`<ul class="s-op-steps">${op.steps.map((s, i) => html`<li key=${i} class=${`s-step ${s.status}`}>
733
743
  <span class="s-step-icon" aria-hidden="true">${icon(s.status) ? html`<${Icon} name=${icon(s.status)} size=${14} />` : "·"}</span>
734
744
  <span class="grow">${s.label}</span>
735
- <span class="note">${s.status === "waiting" ? tn("stepWaiting") : s.status === "skipped" ? tn("stepSkipped") : s.status === "failed" ? (s.error || tn("applyFailed")) : impactText(s.impact)}</span></li>`)}</ul>` : null}
745
+ <span class="note">${s.status === "waiting" ? tn("stepWaiting") : s.status === "skipped" ? tn("stepSkipped") : s.status === "failed" ? (s.error || tn("applyFailed")) : s.note || impactText(s.impact)}</span></li>`)}</ul>` : null}
736
746
  ${rows.length ? html`<ul class="s-op-steps">${rows.map((r) => html`<li key=${r.target} class=${`s-step ${r.status}`}>
737
747
  <span class="s-step-icon" aria-hidden="true">${r.status === "done" ? html`<${Icon} name="check" size=${14} />` : r.status === "failed" ? html`<${Icon} name="alert" size=${14} />` : r.status === "restart-required" ? html`<${Icon} name="restart" size=${14} />` : "·"}</span>
738
748
  <span class="grow">${r.target === "fleet" ? "AgEnD" : shortName(r.target, {})}</span>
739
- <span class="note">${r.target === "fleet" && r.status === "running" ? tn("restartFleetTitle") : r.status === "restart-required" ? tn("applyRestartNeeded")
749
+ <span class="note">${r.target === "fleet" && r.status === "running" ? tn(r.kind === "hot" ? "connStartingNow" : "restartFleetTitle") : r.status === "restart-required" ? tn("applyRestartNeeded")
740
750
  : r.settled_by === "no-change" ? tn("applyNoChange") : r.settled_by === "fleet-restart" ? tn("viaRestart") : r.kind === "hot" ? tn("impactNow") : tn("impactAgent")}</span>
741
751
  ${r.error ? html`<span class="feedback error">${r.error}</span>` : null}</li>`)}</ul>` : null}
742
752
  ${job && job.error ? html`<p class="feedback error">${job.error}</p>` : null}
@@ -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];
@@ -93,6 +110,8 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
93
110
  const [view, setView] = useState("preview");
94
111
  const [run, setRun] = useState({ state: "idle", reason: "" });
95
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)
96
115
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
97
116
  const open = st.open && st.open.instance === name ? st.open : null;
98
117
 
@@ -120,6 +139,7 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
120
139
  }, [open && open.key, st.run]);
121
140
  // A later reply with a newer version of this block: offered, not swapped in.
122
141
  useEffect(() => {
142
+ setNewerNote(""); reading.current = null;
123
143
  if (!open) { setNewer(null); return undefined; }
124
144
  const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
125
145
  check();
@@ -162,6 +182,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
162
182
  el.addEventListener("pointerup", up);
163
183
  el.addEventListener("pointercancel", up);
164
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;
165
200
  // Esc in the panel closes it (and is not the chat's "stop the reply").
166
201
  const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
167
202
 
@@ -188,10 +223,10 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
188
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>
189
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>`
190
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>`}
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>
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>
192
227
  </div>
193
228
  ${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}
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}
195
230
  ${going ? html`<div class="pv-banner">${P().BANNER}</div>` : null}
196
231
  ${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
197
232
  <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
@@ -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