@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
@@ -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;
@@ -19,12 +20,19 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
19
20
 
20
21
  /**
21
22
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
22
- * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code) }.
23
+ * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code), panel? }. panel (#1481): { shown() → the card
24
+ * key the side panel shows, or null; open(spec, { run }) } — without it a card has no "Open in panel".
23
25
  */
24
26
  export function createThread(list, scroller, opts) {
25
27
  const nodes = new Map(); // key → { html, node }
26
28
  let msgs = [];
27
29
  let unseen = 0;
30
+ // The reader is at the newest message. While pinned, content that grows after it was drawn — an image loading, a
31
+ // preview card, the composer growing — keeps the view at the bottom (#1269); a reader who scrolled up is never moved.
32
+ // Only a move UP unpins: the browser's own scroll anchoring also fires scroll events when content grows (the view
33
+ // moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
34
+ let pinned = true;
35
+ let lastTop = 0;
28
36
  const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
29
37
  const tr = opts.t, trf = opts.tf;
30
38
  const stopIn = (node) => (P() ? P().stopIn(node) : []);
@@ -37,7 +45,23 @@ export function createThread(list, scroller, opts) {
37
45
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
38
46
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
39
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>`;
40
- 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>`;
41
65
  }
42
66
  function msgNode(html, x, stoppedKeys) {
43
67
  const tpl = list.ownerDocument.createElement("template");
@@ -69,68 +93,121 @@ export function createThread(list, scroller, opts) {
69
93
  }
70
94
  }
71
95
 
72
- // HTML cards (#1306): each ```html fence of a server-marked agent message gets a card under its code block. Building
73
- // 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.
74
99
  function decorateHtmlCards(node, x, stoppedKeys) {
75
100
  const doc = list.ownerDocument;
76
101
  const fences = R().htmlFences(x.text);
77
102
  for (const ph of node.querySelectorAll(".html-card[data-card]")) {
78
103
  const n = Number(ph.dataset.card), fence = fences[n];
79
104
  if (!fence) continue;
80
- const key = `${msgKey(x)}:f${n}`;
81
105
  ph.textContent = "";
82
106
  if (ph.classList.contains("truncated") || !fence.terminated) {
83
107
  const note = doc.createElement("div"); note.className = "pv-note"; note.textContent = tr("chat.pvTruncated"); ph.append(note);
84
108
  continue;
85
109
  }
86
- const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
87
- const head = el("div", "pv-head");
88
- const label = el("span", "pv-label", tr("chat.pvHtml"));
89
- const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
90
- const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
91
- const dl = el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload")); dl.type = "button";
92
- const menu = el("details", "pv-menu");
93
- const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
94
- const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
95
- const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
96
- menu.append(sum, optB, neverB);
97
- head.append(label, run, stopB, dl, menu);
98
- const note = el("div", "pv-note");
99
- const banner = el("div", "pv-banner", P().BANNER); banner.hidden = true;
100
- const holder = el("div", "pv-holder");
101
- ph.append(head, note, banner, holder);
102
- let state = "idle";
103
- const refresh = (reason) => {
104
- const a = P().availability();
105
- const going = state === "starting" || state === "running";
106
- run.hidden = !a.ok || going;
107
- stopB.hidden = !going;
108
- banner.hidden = !going;
109
- note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : a.ok ? "" : a.reason;
110
- optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
111
- neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
112
- };
113
- const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
114
- run.onclick = () => P().start(key, holder, fence.code, ui);
115
- stopB.onclick = () => P().stop(key, "stopped", "");
116
- dl.onclick = () => opts.download(fence.code);
117
- optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
118
- neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
119
- cardRefresh.set(ph, refresh);
120
- 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);
121
118
  }
122
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
+ }
123
195
  /** Re-read this device's preview choice on every card in this thread. */
124
196
  function refreshCards() {
125
- 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(); }
126
198
  }
127
199
 
128
200
  const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
129
201
  function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
202
+ function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
203
+ // Sizes change without a scroll event (layout after the render): follow them while pinned.
204
+ const resized = () => { if (pinned && !nearBottom()) toBottom(); reportJump(); };
205
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
206
+ if (ro) { ro.observe(list); ro.observe(scroller); }
130
207
 
131
208
  /** Draw `next` (this instance's messages). `restore`: the scroll position to return to (null = the bottom). */
132
209
  function render(next, renderOpts = {}) {
133
- const stick = nearBottom();
210
+ const stick = pinned || nearBottom();
134
211
  msgs = next || [];
135
212
  if (!msgs.length) {
136
213
  for (const v of nodes.values()) stopIn(v.node);
@@ -159,19 +236,31 @@ export function createThread(list, scroller, opts) {
159
236
  }
160
237
  if ("restore" in renderOpts) {
161
238
  unseen = 0;
162
- scroller.scrollTop = renderOpts.restore == null ? scroller.scrollHeight : renderOpts.restore;
163
- } else if (stick) scroller.scrollTop = scroller.scrollHeight;
239
+ if (renderOpts.restore == null) toBottom();
240
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
241
+ } else if (stick) toBottom();
164
242
  else unseen += added;
165
243
  reportJump();
166
244
  }
167
245
 
168
246
  function onScroll() {
169
- const bottom = nearBottom();
247
+ const bottom = nearBottom(), top = scroller.scrollTop;
248
+ if (bottom) pinned = true;
249
+ else if (top < lastTop) pinned = false;
250
+ lastTop = top;
170
251
  if (bottom) unseen = 0;
171
252
  reportJump();
172
253
  return bottom ? null : scroller.scrollTop;
173
254
  }
174
- function jumpLatest() { scroller.scrollTop = scroller.scrollHeight; unseen = 0; reportJump(); }
255
+ function jumpLatest() { toBottom(); unseen = 0; reportJump(); }
256
+ /** Show one message (the panel's "from …" link, #1481): scrolled into view and briefly marked. */
257
+ function reveal(key) {
258
+ const have = nodes.get(key); if (!have) return false;
259
+ if (typeof have.node.scrollIntoView === "function") have.node.scrollIntoView({ block: "center" });
260
+ have.node.classList.add("flash");
261
+ setTimeout(() => { have.node.classList.remove("flash"); }, 1600);
262
+ return true;
263
+ }
175
264
 
176
265
  async function copyFrom(button, text) {
177
266
  const ok = await opts.copyText(text);
@@ -197,13 +286,18 @@ export function createThread(list, scroller, opts) {
197
286
  const folded = box.classList.toggle("folded");
198
287
  el.textContent = folded ? trf("chat.showAll", el.dataset.lines) : tr("chat.showLess");
199
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
+ }
200
293
  }
201
294
 
202
295
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
203
296
  function dispose() {
297
+ if (ro) ro.disconnect();
204
298
  if (P()) P().stopAll("leave");
205
299
  nodes.clear();
206
300
  }
207
301
 
208
- return { render, onScroll, onClick, jumpLatest, refreshCards, dispose, nodes, get unseen() { return unseen; } };
302
+ return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
209
303
  }
@@ -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,168 @@
1
+ // #1468: Fleet → Cache (/ui/fleet/cache) — how often each instance's prompt cache expires between requests, what
2
+ // that costs, and whether keeping it warm would pay off. Behind the session gate (/ui/js/), imported by the Fleet
3
+ // panel. Read-only (Discord-first): it recommends, it changes nothing.
4
+ //
5
+ // One GET /ui/cache?window=… per opening or per choice of window (a person's navigation); no timer re-reads it
6
+ // (#1374). While the server is still reading transcripts the page says so and offers Refresh.
7
+ import { html } from "/assets/app-html.js";
8
+ import { t, register } from "/assets/app-i18n.js";
9
+ import { Empty } from "/assets/ui-states.js";
10
+ import { Icon } from "/assets/ui-icons.js";
11
+
12
+ register("cache", {
13
+ en: {
14
+ title: "Cache", w24h: "24 h", w7d: "7 days", w30d: "30 days", window: "Window",
15
+ fleetExpiry: "Expiry cost", fleetSaving: "Keep-warm would save", fleetRecommended: "Recommended", ofN: "{0} of {1}",
16
+ pricesNote: "List prices checked {0}.", quotaNote: "Subscriptions: Anthropic and OpenAI do not publish how cache reads weigh against a plan's quota, so savings are exact at list price and an estimate in quota terms.",
17
+ estimateNote: "Estimate: Codex does not record cache writes, so a write is taken as the request's uncached input.",
18
+ notPricedNote: "Some models are not in the price table; those instances are compared in input-price units.",
19
+ scanning: "Still reading transcripts — {0} left. Refresh to see more.", scanningFirst: "Reading transcripts for the first time — Refresh to see more.", refresh: "Refresh",
20
+ keepWarm: "Keep warm", leaveOff: "Leave off", estimate: "estimate", notPriced: "not priced",
21
+ requests: "Requests", pastTtl: "Past the TTL ({0})", expired: "Expired (rewrote)", rewritten: "Tokens rewritten",
22
+ expiryCost: "Expiry cost", share: "{0} of its cost", pings: "Keep-warm pings", pingsAfter: "{0} after its last request",
23
+ pingCost: "Ping cost", net: "Net with keep-warm", gaps: "Gaps between requests", ttlLine: "TTL {0}",
24
+ quiet: "Too little activity in this window to tell.",
25
+ neverExpired: "No gap outlasted the {0} cache.", survived: "{0} gaps outlasted the {1} TTL, but the cache was still there.",
26
+ onlyIdle: "No request ever came back to an expired cache: each session went quiet for good. Keep-warm would only send {0} pings nobody reads.",
27
+ saves: "Often idle past the {0} TTL and then used again: keep-warm would save {1} ({2} of the expiry cost).",
28
+ costsMore: "Keep-warm would send about {0} pings per expired request — it would cost {1} more than it saves.",
29
+ costsMoreUnits: "Keep-warm would send about {0} pings per expired request — it would cost more than it saves.",
30
+ unavailable: "Not available", creditBilled: "credit-billed; no cache data", unsupported: "no cache data from this CLI", noData: "no requests read yet",
31
+ shared: "shares its working directory with {0}; their transcripts cannot be told apart",
32
+ empty: "No instances", emptyHint: "Instances show here once the fleet has them.",
33
+ b0: "≤5m", b1: "≤30m", b2: "≤1h", b3: "≤2h", b4: "≤6h", b5: "≤24h", b6: ">24h", tokensUnit: "input-token equivalents",
34
+ },
35
+ "zh-TW": {
36
+ title: "快取", w24h: "24 小時", w7d: "7 天", w30d: "30 天", window: "期間",
37
+ fleetExpiry: "過期成本", fleetSaving: "保溫可省", fleetRecommended: "建議保溫", ofN: "{1} 個中的 {0} 個",
38
+ pricesNote: "牌價查證日期:{0}。", quotaNote: "訂閱方案:Anthropic 與 OpenAI 未公布快取讀取如何計入方案額度,因此節省金額以牌價計算為準,換算成額度則是估算。",
39
+ estimateNote: "估算:Codex 不記錄快取寫入,寫入量以該次請求未命中快取的輸入估算。",
40
+ notPricedNote: "有些模型不在價目表中,這些 instance 以「輸入價格單位」比較。",
41
+ scanning: "仍在讀取 transcript——還剩 {0}。重新整理可看到更多。", scanningFirst: "第一次讀取 transcript 中——重新整理可看到更多。", refresh: "重新整理",
42
+ keepWarm: "建議保溫", leaveOff: "不需保溫", estimate: "估算", notPriced: "未定價",
43
+ requests: "請求數", pastTtl: "超過 TTL({0})", expired: "已過期(重寫)", rewritten: "重寫的 token",
44
+ expiryCost: "過期成本", share: "占其成本 {0}", pings: "保溫 ping 次數", pingsAfter: "其中 {0} 次在最後一次請求之後",
45
+ pingCost: "ping 成本", net: "保溫後淨額", gaps: "請求間隔", ttlLine: "TTL {0}",
46
+ quiet: "這段期間活動太少,無法判斷。",
47
+ neverExpired: "沒有任何間隔超過 {0} 的快取時效。", survived: "有 {0} 個間隔超過 {1} TTL,但快取仍在。",
48
+ onlyIdle: "沒有任何請求在快取過期後回來使用:每個 session 閒置後就不再使用。保溫只會送出 {0} 次沒人讀的 ping。",
49
+ saves: "常閒置超過 {0} TTL 後又再使用:保溫可省 {1}(過期成本的 {2})。",
50
+ costsMore: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多 {1}。",
51
+ costsMoreUnits: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多。",
52
+ unavailable: "不提供", creditBilled: "以 credit 計費,沒有快取資料", unsupported: "這個 CLI 沒有快取資料", noData: "尚未讀到任何請求",
53
+ shared: "與 {0} 共用工作目錄,無法分辨各自的 transcript",
54
+ empty: "沒有 instance", emptyHint: "fleet 有 instance 後會顯示在這裡。",
55
+ b0: "≤5分", b1: "≤30分", b2: "≤1時", b3: "≤2時", b4: "≤6時", b5: "≤24時", b6: ">24時", tokensUnit: "輸入 token 當量",
56
+ },
57
+ });
58
+ const tc = (k, ...v) => t(`cache.${k}`, ...v);
59
+
60
+ export const CACHE_WINDOWS = ["24h", "7d", "30d"];
61
+ const EDGES = [300, 1800, 3600, 7200, 21600, 86400];
62
+
63
+ export function money(usd) {
64
+ if (!(usd > 0)) return "$0";
65
+ if (usd < 0.01) return "<$0.01";
66
+ return usd >= 100 ? `$${Math.round(usd).toLocaleString("en-US")}` : `$${usd.toFixed(2)}`;
67
+ }
68
+ export function tokens(n) {
69
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
70
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
71
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
72
+ return String(Math.round(n));
73
+ }
74
+ export function bytes(n) { return n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${Math.round(n / 1e6)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; }
75
+ export function ttlText(sec) { return sec >= 3600 ? `${sec / 3600} h` : `${Math.round(sec / 60)} min`; }
76
+ const pct = (x) => `${x >= 0.1 ? Math.round(x * 100) : (x * 100).toFixed(1)}%`;
77
+
78
+ /** The one line under an instance's recommendation. */
79
+ export function reasonText(a) {
80
+ const r = a.recommendation, ttl = ttlText(a.ttlSec);
81
+ if (r.reason === "quiet") return tc("quiet");
82
+ if (r.reason === "never_expired") {
83
+ if (a.sim.pastTtl) return tc("survived", a.sim.pastTtl, ttl);
84
+ return a.sim.tailPings ? tc("onlyIdle", a.sim.tailPings.toLocaleString("en-US")) : tc("neverExpired", ttl);
85
+ }
86
+ if (r.reason === "saves") return tc("saves", ttl, a.priced ? money(a.sim.net) : `${tokens(a.sim.net * 1e6)} ${tc("tokensUnit")}`, a.sim.expiryCost > 0 ? pct(a.sim.net / a.sim.expiryCost) : "—");
87
+ return a.priced ? tc("costsMore", r.pingsPerExpiry ?? 0, money(-a.sim.net)) : tc("costsMoreUnits", r.pingsPerExpiry ?? 0);
88
+ }
89
+
90
+ /** Gap histogram: one bar per bucket (square-root scale: a thousand short gaps must not flatten the few long ones). */
91
+ function Histogram({ buckets, ttlSec }) {
92
+ const W = 280, H = 84, top = 14, base = 66, slot = W / buckets.length, bw = slot - 8;
93
+ const max = Math.max(1, ...buckets);
94
+ const ttlAt = EDGES.indexOf(ttlSec);
95
+ const label = `${tc("gaps")}: ${buckets.map((n, i) => `${tc(`b${i}`)} ${n}`).join(", ")}`;
96
+ return html`<svg class="ch-hist" viewBox=${`0 0 ${W} ${H}`} role="img" aria-label=${label} preserveAspectRatio="none">
97
+ ${buckets.map((n, i) => {
98
+ const h = n ? Math.max(2, Math.round((base - top) * Math.sqrt(n / max))) : 0;
99
+ const x = i * slot + 4;
100
+ return html`<g key=${i} class=${ttlAt >= 0 && i > ttlAt ? "ch-col past" : "ch-col"}>
101
+ <rect x=${x} y=${base - h} width=${bw} height=${h} rx="2" />
102
+ <text class="ch-n" x=${x + bw / 2} y=${base - h - 3} text-anchor="middle">${n || ""}</text>
103
+ <text class="ch-l" x=${x + bw / 2} y=${H - 4} text-anchor="middle">${tc(`b${i}`)}</text></g>`;
104
+ })}
105
+ ${ttlAt >= 0 ? html`<g class="ch-ttl"><line x1=${(ttlAt + 1) * slot} x2=${(ttlAt + 1) * slot} y1="4" y2=${base} />
106
+ <text x=${(ttlAt + 1) * slot + 3} y="11">${tc("ttlLine", ttlText(ttlSec))}</text></g>` : null}
107
+ </svg>`;
108
+ }
109
+
110
+ function Metric({ label, value, sub }) {
111
+ return html`<div class="ch-metric"><dt>${label}</dt><dd>${value}${sub ? html`<span class="ch-sub">${sub}</span>` : null}</dd></div>`;
112
+ }
113
+
114
+ function InstanceCard({ r }) {
115
+ const a = r.analysis, s = a.sim, $ = (v) => (a.priced ? money(v) : "—");
116
+ const on = a.recommendation.on;
117
+ return html`<article class=${`ch-card${on ? " on" : ""}`} aria-labelledby=${`ch-${r.name}`}>
118
+ <header class="ch-head">
119
+ <div class="ch-id"><h3 id=${`ch-${r.name}`} class="mono">${r.name}</h3>
120
+ <span class="ch-models">${r.backend}${a.models.length ? ` · ${a.models.join(", ")}` : ""}</span></div>
121
+ <span class=${`ch-rec${on ? " on" : ""}`}>${on ? tc("keepWarm") : tc("leaveOff")}</span>
122
+ </header>
123
+ <p class="ch-reason">${reasonText(a)}${a.estimate ? html` <span class="ch-tag">${tc("estimate")}</span>` : null}${a.priced ? null : html` <span class="ch-tag">${tc("notPriced")}</span>`}</p>
124
+ <dl class="ch-metrics">
125
+ <${Metric} label=${tc("requests")} value=${a.requests.toLocaleString("en-US")} />
126
+ <${Metric} label=${tc("pastTtl", ttlText(a.ttlSec))} value=${s.pastTtl} />
127
+ <${Metric} label=${tc("expired")} value=${s.expired} />
128
+ <${Metric} label=${tc("rewritten")} value=${tokens(s.rewriteTokens)} />
129
+ <${Metric} label=${tc("expiryCost")} value=${$(s.expiryCost)} sub=${a.share != null && a.priced && s.expiryCost > 0 ? tc("share", pct(a.share)) : null} />
130
+ <${Metric} label=${tc("pings")} value=${(s.pings + s.tailPings).toLocaleString("en-US")} sub=${s.tailPings ? tc("pingsAfter", s.tailPings) : null} />
131
+ <${Metric} label=${tc("pingCost")} value=${$(s.pingCost + s.tailCost)} />
132
+ <${Metric} label=${tc("net")} value=${a.priced ? `${s.net >= 0 ? "+" : "−"}${money(Math.abs(s.net))}` : "—"} />
133
+ </dl>
134
+ <${Histogram} buckets=${a.gapBuckets} ttlSec=${a.ttlSec} />
135
+ </article>`;
136
+ }
137
+
138
+ /** The tab's body. `report`: GET /ui/cache's answer; `win`/`setWin`: the window; `refresh`: read again. */
139
+ export function CacheReport({ report, win, setWin, refresh }) {
140
+ const ok = report.instances.filter((r) => r.status === "ok")
141
+ .sort((x, y) => (y.analysis.priced - x.analysis.priced) || (y.analysis.sim.expiryCost - x.analysis.sim.expiryCost));
142
+ const rest = report.instances.filter((r) => r.status !== "ok");
143
+ const f = report.fleet;
144
+ const anyEstimate = ok.some((r) => r.analysis.estimate);
145
+ return html`<div class="ch">
146
+ <div class="ch-bar">
147
+ <div class="seg-inline" role="group" aria-label=${tc("window")}>${CACHE_WINDOWS.map((w) => html`<button key=${w} type="button"
148
+ class=${`btn btn-sm${w === win ? " btn-primary" : ""}`} aria-pressed=${w === win ? "true" : "false"} onClick=${() => setWin(w)}>${tc(`w${w}`)}</button>`)}</div>
149
+ ${!report.scanning.caughtUp || report.scanning.pendingBytes > 0 ? html`<div class="ch-scan" role="status"><span>${report.scanning.pendingBytes > 0
150
+ ? tc("scanning", bytes(report.scanning.pendingBytes)) : tc("scanningFirst")}</span>
151
+ <button type="button" class="btn btn-sm" onClick=${refresh}><${Icon} name="restart" size=${14} />${tc("refresh")}</button></div>` : null}
152
+ </div>
153
+ ${report.instances.length ? html`<section class="ch-fleet card">
154
+ <${Metric} label=${tc("fleetExpiry")} value=${money(f.expiryCost)} />
155
+ <${Metric} label=${tc("fleetSaving")} value=${money(f.saving)} />
156
+ <${Metric} label=${tc("fleetRecommended")} value=${tc("ofN", f.recommended, f.analysed)} />
157
+ </section>` : html`<${Empty} icon="chart" title=${tc("empty")} hint=${tc("emptyHint")} />`}
158
+ <div class="ch-notes note">
159
+ <p>${tc("pricesNote", report.pricesChecked)} ${tc("quotaNote")}</p>
160
+ ${anyEstimate ? html`<p>${tc("estimateNote")}</p>` : null}
161
+ ${f.priced ? null : html`<p>${tc("notPricedNote")}</p>`}
162
+ </div>
163
+ <div class="ch-cards">${ok.map((r) => html`<${InstanceCard} key=${r.name} r=${r} />`)}</div>
164
+ ${rest.length ? html`<ul class="rows ch-rest">${rest.map((r) => html`<li key=${r.name} class="row-item">
165
+ <span class="grow strong mono">${r.name}</span><span class="muted">${r.backend} · ${r.status === "no_data" ? tc("noData")
166
+ : `${tc("unavailable")} (${r.status === "credit_billed" ? tc("creditBilled") : r.status === "shared" ? tc("shared", (r.with || []).join(", ")) : tc("unsupported")})`}</span></li>`)}</ul>` : null}
167
+ </div>`;
168
+ }
@@ -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
+ }