@songsid/agend 2.3.3 → 2.3.4-beta.2

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 (143) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/dist/agent-cli-instructions.md +1 -1
  4. package/dist/auth-api.d.ts +16 -3
  5. package/dist/auth-api.js +22 -6
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +15 -1
  8. package/dist/authority/web-authority.js +27 -10
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +11 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +12 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +7 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +13 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/chat-log-ids.d.ts +48 -0
  19. package/dist/chat-log-ids.js +182 -0
  20. package/dist/chat-log-ids.js.map +1 -0
  21. package/dist/classic-channel-manager.d.ts +8 -1
  22. package/dist/classic-channel-manager.js +24 -2
  23. package/dist/classic-channel-manager.js.map +1 -1
  24. package/dist/config-validator.js +18 -1
  25. package/dist/config-validator.js.map +1 -1
  26. package/dist/daemon.js +8 -1
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/env-file.d.ts +2 -0
  29. package/dist/env-file.js +13 -8
  30. package/dist/env-file.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +42 -1
  32. package/dist/fleet-manager.js +206 -23
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  36. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instance-lifecycle.d.ts +2 -0
  40. package/dist/instance-lifecycle.js +23 -7
  41. package/dist/instance-lifecycle.js.map +1 -1
  42. package/dist/instructions.d.ts +1 -1
  43. package/dist/instructions.js +1 -1
  44. package/dist/instructions.js.map +1 -1
  45. package/dist/interaction-observation.d.ts +1 -0
  46. package/dist/interaction-observation.js +1 -1
  47. package/dist/interaction-observation.js.map +1 -1
  48. package/dist/locale.js +48 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/next-steps.d.ts +12 -2
  51. package/dist/next-steps.js +19 -3
  52. package/dist/next-steps.js.map +1 -1
  53. package/dist/office-share-service.d.ts +57 -0
  54. package/dist/office-share-service.js +76 -11
  55. package/dist/office-share-service.js.map +1 -1
  56. package/dist/office-share.d.ts +8 -0
  57. package/dist/office-share.js +34 -0
  58. package/dist/office-share.js.map +1 -1
  59. package/dist/outbound-schemas.js +1 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/page-share.d.ts +122 -0
  62. package/dist/page-share.js +199 -0
  63. package/dist/page-share.js.map +1 -0
  64. package/dist/public-preview.d.ts +4 -0
  65. package/dist/public-preview.js +5 -4
  66. package/dist/public-preview.js.map +1 -1
  67. package/dist/public-web-gateway.js +5 -0
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +5 -0
  70. package/dist/quickstart-api.js +1 -1
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/quickstart.d.ts +40 -0
  73. package/dist/quickstart.js +174 -28
  74. package/dist/quickstart.js.map +1 -1
  75. package/dist/remote-card-answer.js +2 -0
  76. package/dist/remote-card-answer.js.map +1 -1
  77. package/dist/remote-card-prompt.js +4 -2
  78. package/dist/remote-card-prompt.js.map +1 -1
  79. package/dist/selection-card.d.ts +21 -0
  80. package/dist/selection-card.js +129 -0
  81. package/dist/selection-card.js.map +1 -0
  82. package/dist/settings-api.js +4 -0
  83. package/dist/settings-api.js.map +1 -1
  84. package/dist/settings-change.js +5 -0
  85. package/dist/settings-change.js.map +1 -1
  86. package/dist/settings-effect.js +2 -0
  87. package/dist/settings-effect.js.map +1 -1
  88. package/dist/setup-host.js +2 -2
  89. package/dist/setup-host.js.map +1 -1
  90. package/dist/setup-wizard.js +2 -2
  91. package/dist/setup-wizard.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/ui/chat-store.js +8 -1
  94. package/dist/ui/chat-strings.js +38 -2
  95. package/dist/ui/chat-thread.js +52 -10
  96. package/dist/ui/page-share-dialog.js +191 -0
  97. package/dist/ui/panel-chat.js +14 -0
  98. package/dist/ui/panel-notices.js +98 -0
  99. package/dist/ui/panel-settings.js +9 -2
  100. package/dist/ui/preview-pages.js +41 -0
  101. package/dist/ui/preview-panel-actions.js +161 -0
  102. package/dist/ui/preview-panel.js +209 -28
  103. package/dist/ui/preview-versions.js +155 -0
  104. package/dist/ui/preview.js +88 -3
  105. package/dist/ui/settings-registry.js +1 -0
  106. package/dist/ui/settings-strings.js +4 -0
  107. package/dist/ui/shared/app-i18n.js +2 -2
  108. package/dist/ui/shared/app-route.js +4 -0
  109. package/dist/ui/shared/app-shell.js +5 -3
  110. package/dist/ui/shared/app-store.js +71 -1
  111. package/dist/ui/shared/app-stream.js +1 -1
  112. package/dist/ui/shared/app.css +57 -3
  113. package/dist/ui/shared/app.js +22 -1
  114. package/dist/ui/shared/signin.js +12 -1
  115. package/dist/ui/shared/ui-icons.js +3 -0
  116. package/dist/ui/shared/web-authorize.js +3 -1
  117. package/dist/web-api.d.ts +26 -0
  118. package/dist/web-api.js +180 -1
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-auth.js +7 -1
  121. package/dist/web-auth.js.map +1 -1
  122. package/dist/web-login.d.ts +2 -0
  123. package/dist/web-login.js +3 -1
  124. package/dist/web-login.js.map +1 -1
  125. package/dist/web-notices.d.ts +51 -0
  126. package/dist/web-notices.js +118 -0
  127. package/dist/web-notices.js.map +1 -0
  128. package/dist/web-only-fleet.d.ts +22 -0
  129. package/dist/web-only-fleet.js +13 -0
  130. package/dist/web-only-fleet.js.map +1 -0
  131. package/dist/web-preview.d.ts +32 -3
  132. package/dist/web-preview.js +52 -19
  133. package/dist/web-preview.js.map +1 -1
  134. package/dist/web-session.d.ts +7 -0
  135. package/dist/web-session.js +2 -0
  136. package/dist/web-session.js.map +1 -1
  137. package/dist/web-settings-patch.d.ts +3 -1
  138. package/dist/web-settings-patch.js +4 -1
  139. package/dist/web-settings-patch.js.map +1 -1
  140. package/dist/web-shell-routes.d.ts +2 -0
  141. package/dist/web-shell-routes.js +3 -0
  142. package/dist/web-shell-routes.js.map +1 -1
  143. package/package.json +1 -1
@@ -31,8 +31,14 @@ export const TEXT_PREVIEW_COLUMNS = 50;
31
31
  export const TEXT_CARD_MAX = 1024 * 1024;
32
32
  const P = () => globalThis.AgendPreview;
33
33
  /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
34
- export const STATUS_FOLD_LINES = 6;
35
- export const STATUS_FOLD_CHARS = 600;
34
+ /**
35
+ * #1672 / the user (hmsg-d6ac6dfb): a status line shows two lines — its header ("A → B:") and the first line of the
36
+ * message, each one row (clipped with an ellipsis). Its "open" control shows when that hides something: more lines than
37
+ * two, or a row clipped at the width it is drawn at (measured after each draw and resize: fitStatus). Where nothing is
38
+ * measured (no layout), a line longer than STATUS_LINE_CHARS stands for "clipped".
39
+ */
40
+ export const STATUS_FOLD_LINES = 2;
41
+ export const STATUS_LINE_CHARS = 100;
36
42
  const REPLY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>';
37
43
  const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
38
44
 
@@ -224,15 +230,19 @@ export function createThread(list, scroller, opts) {
224
230
  */
225
231
  function statusHtml(x, time) {
226
232
  const text = String(x.text == null ? "" : x.text);
227
- const lines = R().lineCount(text);
233
+ // Rows split on any line break (a CRLF is one break, never a row and a stray CR); the text itself is not changed.
234
+ const lines = R().lineCount(text.replace(/\r\n?/g, "\n"));
228
235
  const manyLines = lines > STATUS_FOLD_LINES;
229
- const more = manyLines || text.length > STATUS_FOLD_CHARS
230
- ? `<button data-trust="thread-control" type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
231
- : "";
236
+ const overflows = manyLines || text.split(/\r\n?|\n/).some(l => l.length > STATUS_LINE_CHARS);
237
+ // Its control: a short label at the end of line 2; its full name (and whether it is open) for a screen reader. Shown
238
+ // only while the line overflows (class `overflows`, kept by fitStatus at the width it is drawn at).
239
+ const more = `<button data-trust="thread-control" type="button" class="st-more" data-act="toggleFold" aria-expanded="false"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""} aria-label="${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}">${escAttr(manyLines ? trf("chat.showAllShort", lines) : tr("chat.showMoreShort"))}</button>`;
232
240
  const tools = `<div data-trust="thread-control" 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>`;
233
241
  // #1990: the other instance's name opens its chat (an in-app link the router takes: no target).
234
- const body = textLinkSegments(text, x.links).map(sg => sg.href ? `<a data-trust="link" class="st-link" href="${escAttr(sg.href)}">${escAttr(sg.text)}</a>` : escAttr(sg.text)).join("");
235
- return `<div class="msg status-line${more ? " folded" : ""}"><div class="meta">${time}</div><div class="body"><div class="st-text">${body}</div>${more}</div>${tools}</div>`;
242
+ // Each line its own row (folded: the first two, each clipped to one row); the line breaks stay in the text.
243
+ const body = textLinkSegments(text, x.links).map(sg => sg.href ? `<a data-trust="link" class="st-link" href="${escAttr(sg.href)}">${escAttr(sg.text)}</a>` : escAttr(sg.text)).join("")
244
+ .split(/\r\n?|\n/).map(l => `<span class="st-ln">${l}</span>`).join("\n");
245
+ return `<div class="msg status-line folded${overflows ? " overflows" : ""}"><div class="meta">${time}</div><div class="body"><div class="st-text">${body}</div>${more}</div>${tools}</div>`;
236
246
  }
237
247
  /**
238
248
  * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
@@ -610,7 +620,25 @@ export function createThread(list, scroller, opts) {
610
620
  }
611
621
  // Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
612
622
  // stays flush with the bottom; a reader who scrolled up is never moved.
613
- const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
623
+ const resized = () => { fitStatus(); if (pinned && !atBottom()) toBottom(); reportJump(); };
624
+ /**
625
+ * The user (hmsg-d6ac6dfb): a folded status line whose first two rows fit is plain two lines, no control; one with a
626
+ * row clipped at the width it is drawn at gets its "open" control (`overflows`). Measured after each draw and resize;
627
+ * a line with more than two rows always overflows (data-lines). Nothing laid out (no width): the guess it was drawn with.
628
+ */
629
+ function fitStatus() {
630
+ for (const m of list.querySelectorAll(".msg.status-line.folded")) {
631
+ const btn = m.querySelector(".st-more");
632
+ if (!btn || btn.dataset.lines) continue;
633
+ let measured = false, over = false;
634
+ for (const row of [...m.querySelectorAll(".st-ln")].slice(0, STATUS_FOLD_LINES)) {
635
+ if (!row.clientWidth) continue;
636
+ measured = true;
637
+ if (row.scrollWidth > row.clientWidth + 1) over = true;
638
+ }
639
+ if (measured && m.classList.contains("overflows") !== over) m.classList.toggle("overflows", over);
640
+ }
641
+ }
614
642
  let ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
615
643
  if (ro) { ro.observe(list); ro.observe(scroller); }
616
644
  // #2158: the reader's own hand on the thread — a wheel, a touch, the scrollbar (a pointer held), a scrolling key
@@ -725,6 +753,7 @@ export function createThread(list, scroller, opts) {
725
753
  armPolls();
726
754
  rove();
727
755
  progress();
756
+ fitStatus();
728
757
  return added;
729
758
  }
730
759
  /**
@@ -802,13 +831,26 @@ export function createThread(list, scroller, opts) {
802
831
  function onClick(e) {
803
832
  if (onImageClick(e)) return;
804
833
  const el = e.target && e.target.closest ? e.target.closest("[data-act]") : null;
805
- if (!el || !list.contains(el)) return;
834
+ if (!el || !list.contains(el)) {
835
+ // A folded status line opens by a click on its text too (not on a link in it); its button is the keyboard's way.
836
+ const text = e.target && e.target.closest ? e.target.closest(".msg.status-line.folded.overflows .st-text") : null;
837
+ const btn = text && !e.target.closest("a") ? text.parentNode.querySelector(".st-more") : null;
838
+ if (btn && list.contains(btn)) onClick({ target: btn });
839
+ return;
840
+ }
806
841
  const act = el.dataset.act;
807
842
  if (act === "copyMsg") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) copyFrom(el, x.text); }
808
843
  else if (act === "copyCode") { const pre = el.closest(".codeblock")?.querySelector("pre"); if (pre) copyFrom(el, pre.textContent); }
809
844
  else if (act === "toggleFold") {
810
845
  const box = el.closest(".codeblock, .msg.status-line"); if (!box) return;
811
846
  const folded = box.classList.toggle("folded");
847
+ if (box.classList.contains("status-line")) {
848
+ // A status line's own label: short at the end of line 2 while folded, its full name for a screen reader.
849
+ el.setAttribute("aria-expanded", folded ? "false" : "true");
850
+ el.setAttribute("aria-label", !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAll", el.dataset.lines) : tr("chat.showMore"));
851
+ el.textContent = !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAllShort", el.dataset.lines) : tr("chat.showMoreShort");
852
+ return;
853
+ }
812
854
  el.textContent = !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAll", el.dataset.lines) : tr("chat.showMore");
813
855
  } else if (act === "toggleWrap") opts.toggleWrap();
814
856
  else if (act === "replyButton") {
@@ -0,0 +1,191 @@
1
+ // #2911 item 8: page share links on the dashboard. The preview panel's Share page action — a confirm dialog every time
2
+ // (what is shared, that it runs the page's own scripts, for how long), then the link shown once to copy — and, while that
3
+ // page is shared, "Shared until …" with Stop sharing; Settings → Access & security lists the open ones with Stop and
4
+ // Stop all. Design: #2911 issuecomment-6561078095 (approved).
5
+ // GET / POST /ui/page-share, DELETE /ui/page-share/<id|all> (web-api.ts): an admin session (directly or over the public
6
+ // link). The server reads the page itself from the chat history: the page only names the block.
7
+ import { html, useEffect, useState } from "/assets/app-html.js";
8
+ import { t, register } from "/assets/app-i18n.js";
9
+ import { Dialog } from "/assets/ui-dialog.js";
10
+ import { toast } from "/assets/ui-toast.js";
11
+
12
+ register("pshare", {
13
+ en: {
14
+ share: "Share page", shareTitle: "Make a link anyone can open to see this page, for a limited time",
15
+ shared: "Shared until {0}", sharedTitle: "This page is shared: see or stop it",
16
+ title: "Share this page", what: "Anyone with the link can open “{0}” from {1}'s reply ({2}).",
17
+ runs: "It runs the page's own scripts in their browser, apart from this dashboard. They see the page only — not the chat, the agent or the fleet.",
18
+ snapshot: "The link shows the page as it is now; a later version needs a new link. The chat is told when it starts and ends.",
19
+ cdn: "This page may load libraries from cdnjs / jsDelivr; viewers' browsers will request them.",
20
+ how: "For", create: "Share", creating: "Starting the link…", cancel: "Cancel", close: "Close",
21
+ made: "Anyone with this link can open the page until {0}. It is shown only now: copy it.", copy: "Copy link", copied: "Link copied",
22
+ stop: "Stop sharing", stopAll: "Stop all", stopping: "Stopping…", stopped: "The page is no longer shared.", untitled: "this page",
23
+ nowShared: "Shared until {0}. Stop sharing ends the link at once.",
24
+ m15: "15 minutes", h1: "1 hour", h8: "8 hours", h24: "24 hours",
25
+ e_disabled: "Page share links are off on this fleet (web.page_share.enabled: false).",
26
+ e_duration: "That is longer than this fleet allows (web.page_share.max_hours).", e_too_many: "At most 10 pages can be shared at once: stop one first.",
27
+ e_too_large: "This page is too large to share (over 8 MB): download it instead.", e_page_gone: "This page is no longer in the chat's history.",
28
+ e_message_gone: "This page is no longer in the chat's history.", e_not_an_agent_page: "Only a page an agent sent can be shared.",
29
+ e_rate: "Cloudflare is limiting new quick tunnels right now. Try again in a few minutes.", e_tunnel: "The share link's tunnel did not start ({0}). Try again later.",
30
+ failed: "The link could not be made.",
31
+ cardTitle: "Shared pages", cardHint: "Links to single agent pages, made from the HTML preview panel's Share page. Each ends at its time; stop one to end it now.",
32
+ none: "No pages are shared.", from: "from {0}", ends: "until {0}",
33
+ },
34
+ "zh-TW": {
35
+ share: "分享頁面", shareTitle: "建立一個連結,讓任何人在限定時間內開啟這個頁面",
36
+ shared: "分享到 {0}", sharedTitle: "這個頁面正在分享:查看或停止",
37
+ title: "分享這個頁面", what: "拿到連結的人都能開啟 {1} 回覆裡的「{0}」({2})。",
38
+ runs: "頁面本身的腳本會在對方的瀏覽器執行,與這個儀表板分開。對方只看得到這個頁面——看不到對話、agent 或 fleet。",
39
+ snapshot: "連結顯示的是頁面現在的樣子;之後的新版本需要新的連結。開始與結束分享時會告知這個對話。",
40
+ cdn: "這個頁面可能從 cdnjs / jsDelivr 載入函式庫;觀看者的瀏覽器會向它們發出請求。",
41
+ how: "時間", create: "分享", creating: "正在啟動連結…", cancel: "取消", close: "關閉",
42
+ made: "拿到這個連結的人可以開啟頁面到 {0}。連結只會顯示這一次:請複製。", copy: "複製連結", copied: "已複製連結",
43
+ stop: "停止分享", stopAll: "全部停止", stopping: "正在停止…", stopped: "這個頁面已停止分享。", untitled: "這個頁面",
44
+ nowShared: "分享到 {0}。按「停止分享」會立即結束連結。",
45
+ m15: "15 分鐘", h1: "1 小時", h8: "8 小時", h24: "24 小時",
46
+ e_disabled: "這個 fleet 已關閉頁面分享連結(web.page_share.enabled: false)。",
47
+ e_duration: "超過這個 fleet 允許的時間(web.page_share.max_hours)。", e_too_many: "同時最多分享 10 個頁面:先停止一個。",
48
+ e_too_large: "這個頁面太大(超過 8 MB),無法分享:請改用下載。", e_page_gone: "對話紀錄裡已經沒有這個頁面。",
49
+ e_message_gone: "對話紀錄裡已經沒有這個頁面。", e_not_an_agent_page: "只能分享 agent 傳來的頁面。",
50
+ e_rate: "Cloudflare 目前限制新的臨時通道,請過幾分鐘再試。", e_tunnel: "分享連結的通道沒有啟動({0}),請稍後再試。",
51
+ failed: "無法建立連結。",
52
+ cardTitle: "分享中的頁面", cardHint: "單一 agent 頁面的連結,可在 HTML 預覽面板的「分享頁面」建立。每個連結到時間就結束;停止可立即結束。",
53
+ none: "目前沒有分享中的頁面。", from: "來自 {0}", ends: "到 {0} 為止",
54
+ },
55
+ });
56
+ const tp = (k, ...v) => t(`pshare.${k}`, ...v);
57
+ export const DURATIONS = [["m15", 15], ["h1", 60], ["h8", 480], ["h24", 1440]];
58
+ const JSON_HEADERS = { "Content-Type": "application/json" };
59
+ const when = (ms) => { try { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(ms)); } catch { return ""; } };
60
+ /** The end of a share for the bar: the time today, with the day when it is not today (a share may last 24 h). */
61
+ const until = (ms) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? when(ms) : whenLong(ms); };
62
+ const whenLong = (ms) => { try { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)); } catch { return ""; } };
63
+
64
+ /** What a refusal says (the host's `error` and `reason`). */
65
+ export function refusalText(d) {
66
+ const e = d && d.error;
67
+ if (e === "tunnel") return d.reason === "rate-limited" ? tp("e_rate") : tp("e_tunnel", d.reason || "unknown");
68
+ return ["disabled", "duration", "too_many", "too_large", "page_gone", "message_gone", "not_an_agent_page"].includes(e) ? tp(`e_${e}`) : tp("failed");
69
+ }
70
+
71
+ // ── The open page shares, one read for the page (an admin's; anyone else gets null) ──
72
+ let state = { loaded: false, canShare: false, enabled: false, cdn: false, shares: [] };
73
+ const subs = new Set();
74
+ let reading = null, expiry = null;
75
+ function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
76
+ /** Read the open shares again (after a change; at the soonest end). `get`: fetch. */
77
+ export function refreshPageShares(get = globalThis.fetch) {
78
+ if (reading) return reading;
79
+ reading = (async () => {
80
+ const auth = globalThis.AgendAuth;
81
+ const admin = !!(auth && typeof auth.canWrite === "function" && (await Promise.resolve(auth.canWrite()).catch(() => false)) === true);
82
+ let d = null;
83
+ if (admin) { try { const r = await get("/ui/page-share", { method: "GET", headers: JSON_HEADERS }); d = r.ok ? await r.json() : null; } catch { d = null; } }
84
+ const shares = d && Array.isArray(d.shares) ? d.shares : [];
85
+ set({ loaded: true, canShare: admin && !!d && d.enabled !== false, enabled: !!d && d.enabled !== false, cdn: !!d && d.cdn === true, shares });
86
+ // Read again when the soonest one ends, so its "Shared until" goes without a reload.
87
+ if (expiry) clearTimeout(expiry);
88
+ const soonest = Math.min(...shares.map((s) => s.expiresAt));
89
+ if (Number.isFinite(soonest)) expiry = setTimeout(() => { expiry = null; refreshPageShares(get); }, Math.max(1000, soonest - Date.now() + 1000));
90
+ })().finally(() => { reading = null; });
91
+ return reading;
92
+ }
93
+ export const pageShares = () => state;
94
+ /** The open shares, read once per page and on every change. */
95
+ export function usePageShares(get) {
96
+ const [st, setSt] = useState(state);
97
+ useEffect(() => { subs.add(setSt); if (!state.loaded) refreshPageShares(get); return () => { subs.delete(setSt); }; }, []);
98
+ return st;
99
+ }
100
+ /** The open share of a block `from` ({ boot, id, fence | att }) in `instance`, or null. */
101
+ export function shareOf(shares, instance, from) {
102
+ if (!from) return null;
103
+ return shares.find((s) => s.instance === instance && s.from && s.from.boot === from.boot && s.from.id === from.id
104
+ && (from.att ? s.from.att === from.att : s.from.fence === from.fence)) || null;
105
+ }
106
+
107
+ async function stopShare(id, get = globalThis.fetch) {
108
+ try { await get(`/ui/page-share/${id}`, { method: "DELETE", headers: JSON_HEADERS }); } catch { /* the list is read again */ }
109
+ await refreshPageShares(get);
110
+ }
111
+
112
+ /**
113
+ * The confirm dialog: what is shared (its title, whose reply, when), that it runs the page's own scripts, for how long;
114
+ * Share makes the link and shows it once. With `share` (this page is already shared): when it ends, and Stop sharing.
115
+ * `block`: { instance, from: {boot, id, fence | att}, title, sender, ts }.
116
+ */
117
+ export function PageShareDialog({ block, share = null, get = globalThis.fetch, onClose }) {
118
+ const [minutes, setMinutes] = useState(60);
119
+ const [busy, setBusy] = useState(false);
120
+ const [made, setMade] = useState(null);
121
+ const [error, setError] = useState("");
122
+ const create = async () => {
123
+ setBusy(true); setError("");
124
+ try {
125
+ const r = await get("/ui/page-share", { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ instance: block.instance, boot: block.from.boot, id: block.from.id,
126
+ ...(block.from.att ? { att: block.from.att } : { fence: block.from.fence }), minutes }) });
127
+ const d = await r.json().catch(() => ({}));
128
+ if (r.ok && d && typeof d.url === "string") setMade(d); else setError(refusalText(d));
129
+ } catch { setError(tp("failed")); }
130
+ setBusy(false);
131
+ refreshPageShares(get);
132
+ };
133
+ const stop = async (id) => { setBusy(true); await stopShare(id, get); setBusy(false); toast(tp("stopped")); onClose(); };
134
+ const copy = async () => { try { await navigator.clipboard.writeText(made.url); toast(tp("copied")); } catch { /* the link is on screen to copy by hand */ } };
135
+ const open = made ? made.share : share;
136
+ const name = block.title || tp("untitled");
137
+ const actions = made || share
138
+ ? html`<button type="button" class="btn danger ps-stop" disabled=${busy} onClick=${() => stop(open.id)}>${busy ? tp("stopping") : tp("stop")}</button>
139
+ <button type="button" class="btn" onClick=${onClose} disabled=${busy}>${tp("close")}</button>`
140
+ : html`<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${tp("cancel")}</button>
141
+ <button type="button" class="btn btn-primary ps-create" onClick=${create} disabled=${busy}>${busy ? tp("creating") : tp("create")}</button>`;
142
+ return html`<${Dialog} title=${tp("title")} onClose=${onClose} busy=${busy} actions=${actions}>
143
+ <div class="os ps">
144
+ <p class="ps-what">${tp("what", name, block.sender || block.instance, whenLong(block.ts ? Date.parse(block.ts) : Date.now()))}</p>
145
+ <p class="ps-runs"><strong>${tp("runs")}</strong></p>
146
+ ${made ? html`<div class="os-made">
147
+ <input class="input mono os-url ps-url" readonly value=${made.url} aria-label=${tp("copy")} onFocus=${(e) => e.target.select && e.target.select()} />
148
+ <button type="button" class="btn btn-sm ps-copy" onClick=${copy}>${tp("copy")}</button>
149
+ <p class="note">${tp("made", whenLong(made.share.expiresAt))}</p>
150
+ </div>`
151
+ : share ? html`<p class="note ps-now">${tp("nowShared", whenLong(share.expiresAt))}</p>${share.cdn ? html`<p class="note ps-cdn">${tp("cdn")}</p>` : null}`
152
+ : html`<p class="muted">${tp("snapshot")}</p>
153
+ ${state.cdn ? html`<p class="note ps-cdn">${tp("cdn")}</p>` : null}
154
+ <label class="field"><span>${tp("how")}</span>
155
+ <select class="tb-select ps-how" value=${String(minutes)} onChange=${(e) => setMinutes(Number(e.target.value))} disabled=${busy}>
156
+ ${DURATIONS.map(([k, m]) => html`<option key=${k} value=${String(m)}>${tp(k)}</option>`)}
157
+ </select></label>`}
158
+ ${error ? html`<p class="err os-err ps-err" role="alert">${error}</p>` : null}
159
+ </div>
160
+ <//>`;
161
+ }
162
+
163
+ /** Settings → Access & security: the shared pages, with Stop and Stop all. */
164
+ export function PageSharesCard({ get = globalThis.fetch }) {
165
+ const st = usePageShares(get);
166
+ const [busy, setBusy] = useState(false);
167
+ if (!st.loaded || !st.canShare && !st.shares.length) return null; // not kept on this fleet, or not this session's
168
+ const stop = async (id) => { setBusy(true); await stopShare(id, get); setBusy(false); };
169
+ return html`<section class="card os-card ps-card"><h3>${tp("cardTitle")}</h3>
170
+ <p class="note">${st.enabled ? tp("cardHint") : tp("e_disabled")}</p>
171
+ ${!st.shares.length ? html`<p class="muted os-none">${tp("none")}</p>` : html`<div class="os-list">
172
+ <ul class="os-rows">${st.shares.map((s) => html`<li key=${s.id} class="os-row">
173
+ <span class="os-what"><strong>${s.title || tp("untitled")}</strong>
174
+ <span class="muted"> · ${tp("from", s.instance)} · ${tp("ends", whenLong(s.expiresAt))}</span></span>
175
+ <button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => stop(s.id)}>${tp("stop")}</button>
176
+ </li>`)}</ul>
177
+ ${st.shares.length > 1 ? html`<button type="button" class="btn btn-sm danger os-all" disabled=${busy} onClick=${() => stop("all")}>${tp("stopAll")}</button>` : null}
178
+ </div>`}
179
+ </section>`;
180
+ }
181
+
182
+ /** The panel's action: Share page, or "Shared until …" when this block is shared. `block` as PageShareDialog's. */
183
+ export function PageShareButton({ block, Icon }) {
184
+ const st = usePageShares();
185
+ const [open, setOpen] = useState(false);
186
+ if (!block || !st.canShare) return null;
187
+ const share = shareOf(st.shares, block.instance, block.from);
188
+ return html`<button type="button" class=${`btn btn-sm ${share ? "pv-fb-shared on" : "pv-fb-share"}`} title=${tp(share ? "sharedTitle" : "shareTitle")} onClick=${() => setOpen(true)}>
189
+ <${Icon} name="globe" size=${14} /><span class="lbl">${share ? tp("shared", until(share.expiresAt)) : tp("share")}</span></button>
190
+ ${open ? html`<${PageShareDialog} block=${block} share=${share} onClose=${() => setOpen(false)} />` : null}`;
191
+ }
@@ -76,6 +76,7 @@ import { installMessageActions } from "./chat-msg-actions.js";
76
76
  import { createEmojiIndex } from "./chat-emojis.js";
77
77
  import { pollPublicPreview } from "./preview-status.js";
78
78
  import { createMentionIndex } from "./chat-mentions.js";
79
+ import { setPanelFeedback } from "./preview-panel-actions.js";
79
80
  import { PreviewPanel, claimPanel, openPanel, panelClaims, panelStore, panelVersion, shownKey } from "./preview-panel.js";
80
81
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
81
82
  import { installTour, refreshTourSpot } from "./chat-tour.js";
@@ -142,6 +143,19 @@ export function boot({ stream, boot: bootData, deps = {} }) {
142
143
  if (auth && typeof auth.sessionHandle === "function") auth.sessionHandle().then((h) => P().setBannerScope(h), () => P().setBannerScope(null));
143
144
  else P().setBannerScope(null);
144
145
  }
146
+ // #2911: the preview panel's "Reply about this page" reaches the composer through these (an undoable edit, as typing).
147
+ setPanelFeedback({
148
+ findMsg: (instance, key) => (store.state.msgs[instance] || []).find((x) => msgKey(x) === key) || null,
149
+ startReply: (instance, x) => store.startReply(instance, x),
150
+ clearReply: (instance) => store.clearReply(instance),
151
+ addToDraft: (instance, line) => { recordEdit(composerField()); store.addToDraft(instance, line); },
152
+ focus: () => {
153
+ const el = composerField(); if (!el) return;
154
+ el.focus();
155
+ // After the render that shows the line: the caret at its end.
156
+ setTimeout(() => { try { const n = el.value.length; el.setSelectionRange(n, n); } catch { /* not a text field */ } }, 0);
157
+ },
158
+ });
145
159
  // #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
146
160
  installTour();
147
161
  return store;
@@ -0,0 +1,98 @@
1
+ // #2910 D2: a web-only fleet's Notices (/ui/notices) — what General would have said: fleet errors, an agent's crash,
2
+ // quota or sign-in notice, a schedule that could not run, an available update. Behind the session gate (/ui/js/),
3
+ // loaded the first time it is opened. The list is the app store's `notices` (read from GET /ui/notices/list, then each
4
+ // new one from SSE `notice`); opening the page marks what it shows as read (POST /ui/notices/seen).
5
+ import { html, useEffect, useState } from "/assets/app-html.js";
6
+ import { t, register } from "/assets/app-i18n.js";
7
+ import { appStore, applyNoticesSeen, useStore } from "/assets/app-store.js";
8
+ import { useLease } from "/assets/app-ctx.js";
9
+ import { PanelHeader, setTitle } from "/assets/app-shell.js";
10
+ import { chatPath } from "/assets/app-route.js";
11
+ import { Empty } from "/assets/ui-states.js";
12
+ import { Icon } from "/assets/ui-icons.js";
13
+
14
+ register("notices", {
15
+ en: {
16
+ title: "Notices", sub: "What General would have said", empty: "No notices yet",
17
+ emptyHint: "This fleet has no Discord or Telegram connection, so what its General would say — errors, an agent that crashed or ran out of quota, a sign-in, an available update — is kept here.",
18
+ unread: "New", open: "Open chat",
19
+ kind_fleet: "Fleet", kind_instance: "Agent", kind_update: "Update", kind_schedule: "Schedule", kind_summary: "Daily summary",
20
+ ageNow: "just now", ageMin: "{0} min ago", ageHour: "{0} h ago", ageDay: "{0} d ago",
21
+ },
22
+ "zh-TW": {
23
+ title: "通知", sub: "原本會發到 General 的訊息", empty: "還沒有通知",
24
+ emptyHint: "這個 fleet 沒有 Discord 或 Telegram 連線,所以原本會由 General 說的事——錯誤、agent 當掉或額度用完、登入、新版本——都會留在這裡。",
25
+ unread: "新", open: "開啟聊天",
26
+ kind_fleet: "Fleet", kind_instance: "Agent", kind_update: "更新", kind_schedule: "排程", kind_summary: "每日摘要",
27
+ ageNow: "剛剛", ageMin: "{0} 分鐘前", ageHour: "{0} 小時前", ageDay: "{0} 天前",
28
+ },
29
+ });
30
+ const tn = (k, ...v) => t(`notices.${k}`, ...v);
31
+ const ICON = { fleet: "alert", instance: "bot", update: "download", schedule: "clock", summary: "chart" };
32
+
33
+ /** "3 min ago": a notice's age, refreshed by a display timer. */
34
+ export function noticeAge(at, nowMs) {
35
+ const minutes = Math.max(0, Math.floor((nowMs - at) / 60_000));
36
+ if (minutes < 1) return tn("ageNow");
37
+ if (minutes < 60) return tn("ageMin", minutes);
38
+ const hours = Math.floor(minutes / 60);
39
+ return hours < 24 ? tn("ageHour", hours) : tn("ageDay", Math.floor(hours / 24));
40
+ }
41
+
42
+ /** The highest id a mark was sent for (a second ask for it, while the first is out or after it landed, sends nothing). */
43
+ let markAsked = 0;
44
+ /** Everything up to `id` is read: the server's mark, then the store's (a failure leaves both as they were). */
45
+ export async function markNoticesSeen(id) {
46
+ if (id <= markAsked) return;
47
+ markAsked = id;
48
+ try {
49
+ const r = await fetch("/ui/notices/seen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }) });
50
+ const d = r.ok ? await r.json() : null;
51
+ // The mark only moves forward, and unread is counted from what the page has (a notice that arrived meanwhile stays unread).
52
+ if (d && typeof d.seen === "number") { const cur = appStore.get().notices, next = applyNoticesSeen(cur, d.seen); if (next !== cur) appStore.set({ notices: next }); }
53
+ else markAsked = Math.min(markAsked, id - 1);
54
+ } catch { markAsked = Math.min(markAsked, id - 1); /* stays unread; the next view marks it */ }
55
+ }
56
+
57
+ /** The newest notice the page has, marked read — while the page is visible and it is not read yet. */
58
+ function markShown() {
59
+ const cur = appStore.get().notices, top = cur && cur.items.length ? cur.items[0].id : 0;
60
+ if (!top || top <= (cur.seen | 0)) return;
61
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
62
+ markNoticesSeen(top);
63
+ }
64
+
65
+ export function NoticesPanel({ navKey }) {
66
+ const lease = useLease(navKey);
67
+ const { notices, instances } = useStore(appStore);
68
+ const items = notices && Array.isArray(notices.items) ? notices.items : [];
69
+ // What was unread when the page opened stays marked "New" while it is open (it is marked read on the server at once).
70
+ const [seenAtOpen] = useState(() => (notices ? notices.seen | 0 : 0));
71
+ const [, tick] = useState(0);
72
+ useEffect(() => { setTitle(tn("title")); const read = appStore.get().readNotices; if (read) read(); }, [navKey]);
73
+ useEffect(() => { lease.interval(() => tick((n) => n + 1), 30_000); }, [lease]);
74
+ // Shown → read: the newest one on the page, whenever a newer one arrives while it is open and visible.
75
+ const newest = items.length ? items[0].id : 0;
76
+ // …and, if one arrived while the tab was hidden, when it is shown again (#2925 review): one listener per visit.
77
+ useEffect(() => { markShown(); }, [newest]);
78
+ useEffect(() => { if (typeof document !== "undefined") lease.on(document, "visibilitychange", markShown); }, [lease]);
79
+ const known = new Set((instances || []).map((i) => i && i.name));
80
+ const nowMs = Date.now();
81
+ return html`<div data-effect-panel="notices" class="panel p-notices">
82
+ <${PanelHeader} title=${tn("title")} />
83
+ <div data-trust="panel-content" class="panel-body"><div class="col">
84
+ ${items.length ? html`<p class="note notice-sub">${tn("sub")}</p>` : null}
85
+ ${!items.length ? html`<${Empty} icon="bell" title=${tn("empty")} hint=${tn("emptyHint")} />`
86
+ : items.map((n) => html`<article key=${n.id} class=${`n-item notice${n.id > seenAtOpen ? " unread" : ""}${n.kind === "fleet" ? " bad" : ""}`}>
87
+ <span class="n-icon"><${Icon} name=${ICON[n.kind] || "info"} size=${18} /></span>
88
+ <div class="n-body">
89
+ <div class="n-title"><strong>${tn(`kind_${n.kind}`)}${n.instance ? ` · ${n.instance}` : ""}</strong>
90
+ ${n.id > seenAtOpen ? html`<span class="notice-new">${tn("unread")}</span>` : null}
91
+ <span class="n-age" title=${new Date(n.at).toLocaleString()}>${noticeAge(n.at, nowMs)}</span></div>
92
+ <p class="n-detail notice-text">${n.text}</p>
93
+ ${n.instance && known.has(n.instance) ? html`<div class="n-actions"><a class="btn btn-sm btn-ghost" href=${chatPath(n.instance)}>${tn("open")}</a></div>` : null}
94
+ </div>
95
+ </article>`)}
96
+ </div></div>
97
+ </div>`;
98
+ }
@@ -48,6 +48,7 @@ import "./chat-strings.js";
48
48
  import { AppearanceSection } from "./settings-appearance.js";
49
49
  import { GuestsCard } from "./settings-guests.js";
50
50
  import { OfficeSharesCard } from "./office-share-dialog.js";
51
+ import { PageSharesCard } from "./page-share-dialog.js";
51
52
  import { syncStage, useDraft } from "./settings-draft.js";
52
53
  import { WebAuthorityCard } from "./settings-web-authority.js";
53
54
  import { NamedLinkTest, setTypedTunnelToken } from "./settings-named-test.js";
@@ -1200,13 +1201,14 @@ function WebSection({ ctx, staged, part = "all" }) {
1200
1201
  log: web.log_tab === true, // #1218: off by default
1201
1202
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
1202
1203
  levels: web.office_levels !== false, // #2661: on by default
1204
+ cdn: web.preview_cdn === true, // #2911 (7): off by default
1203
1205
  pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2", hostname: (web.public_link && web.public_link.hostname) ?? "", tunnel_token_env: (web.public_link && web.public_link.tunnel_token_env) ?? "", origin_port: (web.public_link && web.public_link.origin_port) ?? 19282, auto_open: (web.public_link && web.public_link.auto_open) ?? false },
1204
1206
  session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
1205
1207
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
1206
1208
  }), []);
1207
1209
  const text = (v) => (v === null || v === undefined ? "" : String(v));
1208
1210
  /** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
1209
- const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, levels: original.levels, pub: original.pub.allow_public,
1211
+ const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, levels: original.levels, cdn: original.cdn, pub: original.pub.allow_public,
1210
1212
  ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
1211
1213
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
1212
1214
  termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
@@ -1220,7 +1222,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1220
1222
  return {
1221
1223
  notify: String(pick(pending, "notify_login", original.notify)),
1222
1224
  echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
1223
- levels: pick(pending, "office_levels", original.levels),
1225
+ levels: pick(pending, "office_levels", original.levels), cdn: pick(pending, "preview_cdn", original.cdn),
1224
1226
  pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
1225
1227
  hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
1226
1228
  abs: text(pick(session, "absolute_hours", original.session.absolute_hours)), idle: text(pick(session, "idle_hours", original.session.idle_hours)),
@@ -1273,6 +1275,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1273
1275
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
1274
1276
  ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
1275
1277
  ...(f.levels !== original.levels ? { office_levels: f.levels } : {}),
1278
+ ...(f.cdn !== original.cdn ? { preview_cdn: f.cdn } : {}),
1276
1279
  ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
1277
1280
  ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
1278
1281
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
@@ -1283,6 +1286,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1283
1286
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
1284
1287
  ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
1285
1288
  ...(body.office_levels !== undefined ? ["web.office_levels"] : []),
1289
+ ...(body.preview_cdn !== undefined ? ["web.preview_cdn"] : []),
1286
1290
  ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
1287
1291
  ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
1288
1292
  ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
@@ -1365,6 +1369,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1365
1369
  </section>`) : null}
1366
1370
  ${has("access") && !overPublicLink ? html`<${PublicTunnelToken} />` : null}
1367
1371
  ${has("access") ? html`<${OfficeSharesCard} />` : null}
1372
+ ${has("access") ? html`<${PageSharesCard} />` : null}
1368
1373
  ${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
1369
1374
  ${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
1370
1375
  <label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
@@ -1377,6 +1382,8 @@ function WebSection({ ctx, staged, part = "all" }) {
1377
1382
  <p class="note">${tn("webUsagePublicHint")}</p>
1378
1383
  <label class=${fc("check", "levels")}><input id="webOfficeLevels" type="checkbox" checked=${f.levels} onChange=${(e) => set("levels")(e.target.checked)} /><span>${tn("webOfficeLevels")}</span><${Impact} schema=${schema} field="web.office_levels" /></label>
1379
1384
  <p class="note">${tn("webOfficeLevelsHint")}</p>
1385
+ <label class=${fc("check", "cdn")}><input id="webPreviewCdn" type="checkbox" checked=${f.cdn} onChange=${(e) => set("cdn")(e.target.checked)} /><span>${tn("webPreviewCdn")}</span><${Impact} schema=${schema} field="web.preview_cdn" /></label>
1386
+ <p class="note">${tn("webPreviewCdnHint")}</p>
1380
1387
  </section>`) : null}
1381
1388
  ${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
1382
1389
  <p class="note">${tn("webReadOnlyLead")}</p>
@@ -0,0 +1,41 @@
1
+ // #2911 (6): every page in the thread — the list the preview panel shows. A page is what preview-versions.js counts as
2
+ // versions of one thing: the agent's complete ```html blocks with one <title> (the untitled ones together), or its
3
+ // attached .html files of one name. Pure: the panel draws it; nothing here runs a preview.
4
+ import "./chat-render.js";
5
+ import { htmlTitle } from "./preview-versions.js";
6
+
7
+ const R = () => globalThis.AgendChatRender;
8
+
9
+ /**
10
+ * The pages in `msgs` (in thread order), newest first: [{ id, label, kind: "html" | "file", versions, latest }], where
11
+ * `latest` is the newest version as the panel opens it ({ key, instance, msgKey, n | att, code, sender, ts }; an
12
+ * attached file's code is read when it is opened). `label` is the title, the file name, or "" for untitled blocks.
13
+ */
14
+ export function pagesOf(msgs, keyOf, instance) {
15
+ const pages = new Map();
16
+ const add = (id, label, kind, spec) => {
17
+ const p = pages.get(id);
18
+ if (p) { p.versions++; p.latest = spec; pages.delete(id); pages.set(id, p); } // re-inserted: newest last
19
+ else pages.set(id, { id, label, kind, versions: 1, latest: spec });
20
+ };
21
+ for (const x of Array.isArray(msgs) ? msgs : []) {
22
+ if (x.role !== "agent") continue;
23
+ const msgKey = keyOf(x);
24
+ R().htmlFences(x.text).forEach((f, n) => {
25
+ if (!f.terminated) return;
26
+ const title = htmlTitle(f.code);
27
+ add(`t:${title}`, title, "html", { key: `${msgKey}:f${n}`, instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts });
28
+ });
29
+ for (const a of Array.isArray(x.attachments) ? x.attachments : []) {
30
+ if (!R().isHtmlAttachment(a)) continue;
31
+ add(`a:${a.name.trim().toLowerCase()}`, a.name, "file", { key: `${msgKey}:a${a.id}`, instance, msgKey, att: { id: a.id, name: a.name, size: a.size }, code: null, sender: x.sender, ts: x.ts });
32
+ }
33
+ }
34
+ return [...pages.values()].reverse();
35
+ }
36
+
37
+ /** The page `open` belongs to: its id in pagesOf's terms. */
38
+ export function pageIdOf(open) {
39
+ if (!open) return null;
40
+ return open.att ? `a:${String(open.att.name || "").trim().toLowerCase()}` : `t:${htmlTitle(open.code)}`;
41
+ }