@songsid/agend 2.3.3-beta.9 → 2.3.4-beta.1

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 (154) 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/config-validator.js +18 -1
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.js +8 -1
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/env-file.d.ts +2 -0
  21. package/dist/env-file.js +13 -8
  22. package/dist/env-file.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +41 -1
  24. package/dist/fleet-manager.js +179 -22
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  28. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  29. package/dist/instance-config-impact.js +1 -0
  30. package/dist/instance-config-impact.js.map +1 -1
  31. package/dist/instance-lifecycle.d.ts +2 -0
  32. package/dist/instance-lifecycle.js +23 -7
  33. package/dist/instance-lifecycle.js.map +1 -1
  34. package/dist/instructions.d.ts +1 -1
  35. package/dist/instructions.js +1 -1
  36. package/dist/instructions.js.map +1 -1
  37. package/dist/interaction-observation.d.ts +1 -0
  38. package/dist/interaction-observation.js +1 -1
  39. package/dist/interaction-observation.js.map +1 -1
  40. package/dist/locale.js +56 -2
  41. package/dist/locale.js.map +1 -1
  42. package/dist/next-steps.d.ts +12 -2
  43. package/dist/next-steps.js +19 -3
  44. package/dist/next-steps.js.map +1 -1
  45. package/dist/office-share-service.d.ts +57 -0
  46. package/dist/office-share-service.js +76 -11
  47. package/dist/office-share-service.js.map +1 -1
  48. package/dist/office-share.d.ts +8 -0
  49. package/dist/office-share.js +34 -0
  50. package/dist/office-share.js.map +1 -1
  51. package/dist/outbound-schemas.js +1 -1
  52. package/dist/outbound-schemas.js.map +1 -1
  53. package/dist/page-share.d.ts +122 -0
  54. package/dist/page-share.js +199 -0
  55. package/dist/page-share.js.map +1 -0
  56. package/dist/public-link-progress.d.ts +5 -2
  57. package/dist/public-link-progress.js +4 -3
  58. package/dist/public-link-progress.js.map +1 -1
  59. package/dist/public-preview.d.ts +4 -0
  60. package/dist/public-preview.js +5 -4
  61. package/dist/public-preview.js.map +1 -1
  62. package/dist/public-web-gateway.js +5 -0
  63. package/dist/public-web-gateway.js.map +1 -1
  64. package/dist/public-web-link.d.ts +10 -0
  65. package/dist/public-web-link.js +24 -5
  66. package/dist/public-web-link.js.map +1 -1
  67. package/dist/quickstart-api.d.ts +5 -0
  68. package/dist/quickstart-api.js +1 -1
  69. package/dist/quickstart-api.js.map +1 -1
  70. package/dist/quickstart.d.ts +40 -0
  71. package/dist/quickstart.js +174 -28
  72. package/dist/quickstart.js.map +1 -1
  73. package/dist/remote-card-answer.js +2 -0
  74. package/dist/remote-card-answer.js.map +1 -1
  75. package/dist/remote-card-prompt.js +4 -2
  76. package/dist/remote-card-prompt.js.map +1 -1
  77. package/dist/selection-card.d.ts +21 -0
  78. package/dist/selection-card.js +129 -0
  79. package/dist/selection-card.js.map +1 -0
  80. package/dist/settings-api.d.ts +10 -0
  81. package/dist/settings-api.js +45 -0
  82. package/dist/settings-api.js.map +1 -1
  83. package/dist/settings-change.js +5 -0
  84. package/dist/settings-change.js.map +1 -1
  85. package/dist/settings-effect.js +2 -0
  86. package/dist/settings-effect.js.map +1 -1
  87. package/dist/setup-host.js +2 -2
  88. package/dist/setup-host.js.map +1 -1
  89. package/dist/setup-wizard.js +2 -2
  90. package/dist/setup-wizard.js.map +1 -1
  91. package/dist/tunnel/cloudflared.d.ts +5 -0
  92. package/dist/tunnel/cloudflared.js +32 -1
  93. package/dist/tunnel/cloudflared.js.map +1 -1
  94. package/dist/tunnel/named-diagnostics.d.ts +2 -1
  95. package/dist/tunnel/named-diagnostics.js +4 -1
  96. package/dist/tunnel/named-diagnostics.js.map +1 -1
  97. package/dist/tunnel/named-link-test.d.ts +90 -0
  98. package/dist/tunnel/named-link-test.js +270 -0
  99. package/dist/tunnel/named-link-test.js.map +1 -0
  100. package/dist/tunnel/purpose-lane.d.ts +3 -1
  101. package/dist/tunnel/purpose-lane.js +2 -0
  102. package/dist/tunnel/purpose-lane.js.map +1 -1
  103. package/dist/types.d.ts +10 -0
  104. package/dist/ui/chat-store.js +8 -1
  105. package/dist/ui/chat-strings.js +36 -0
  106. package/dist/ui/page-share-dialog.js +191 -0
  107. package/dist/ui/panel-chat.js +14 -0
  108. package/dist/ui/panel-notices.js +98 -0
  109. package/dist/ui/panel-settings.js +14 -5
  110. package/dist/ui/preview-pages.js +41 -0
  111. package/dist/ui/preview-panel-actions.js +161 -0
  112. package/dist/ui/preview-panel.js +209 -28
  113. package/dist/ui/preview-versions.js +155 -0
  114. package/dist/ui/preview.js +88 -3
  115. package/dist/ui/settings-named-test.js +144 -0
  116. package/dist/ui/settings-registry.js +1 -0
  117. package/dist/ui/settings-strings.js +10 -6
  118. package/dist/ui/shared/app-i18n.js +2 -2
  119. package/dist/ui/shared/app-route.js +4 -0
  120. package/dist/ui/shared/app-shell.js +5 -3
  121. package/dist/ui/shared/app-store.js +71 -1
  122. package/dist/ui/shared/app-stream.js +1 -1
  123. package/dist/ui/shared/app.css +50 -1
  124. package/dist/ui/shared/app.js +22 -1
  125. package/dist/ui/shared/signin.js +12 -1
  126. package/dist/ui/shared/ui-icons.js +3 -0
  127. package/dist/ui/shared/web-authorize.js +5 -3
  128. package/dist/web-api.d.ts +26 -0
  129. package/dist/web-api.js +180 -1
  130. package/dist/web-api.js.map +1 -1
  131. package/dist/web-auth.js +7 -1
  132. package/dist/web-auth.js.map +1 -1
  133. package/dist/web-login.d.ts +2 -0
  134. package/dist/web-login.js +3 -1
  135. package/dist/web-login.js.map +1 -1
  136. package/dist/web-notices.d.ts +51 -0
  137. package/dist/web-notices.js +118 -0
  138. package/dist/web-notices.js.map +1 -0
  139. package/dist/web-only-fleet.d.ts +22 -0
  140. package/dist/web-only-fleet.js +13 -0
  141. package/dist/web-only-fleet.js.map +1 -0
  142. package/dist/web-preview.d.ts +32 -3
  143. package/dist/web-preview.js +52 -19
  144. package/dist/web-preview.js.map +1 -1
  145. package/dist/web-session.d.ts +7 -0
  146. package/dist/web-session.js +2 -0
  147. package/dist/web-session.js.map +1 -1
  148. package/dist/web-settings-patch.d.ts +3 -1
  149. package/dist/web-settings-patch.js +4 -1
  150. package/dist/web-settings-patch.js.map +1 -1
  151. package/dist/web-shell-routes.d.ts +2 -0
  152. package/dist/web-shell-routes.js +3 -0
  153. package/dist/web-shell-routes.js.map +1 -1
  154. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ // #2906 (the user, 2026-11-01): Settings → Access → the fixed address's 「測試連線」 / "Test connection". It tests the
2
+ // values as filled in (unsaved too: the typed connector token, else the saved one) and shows each step ✓ or ✗; every ✗
3
+ // says what the person can do next. The server answers closed step codes only (settings-api.ts, named-link-test.ts):
4
+ // the token never comes back. Words never name the person's workplace — "this machine's network", "network administrator".
5
+ import { html, useRef, useState } from "/assets/app-html.js";
6
+ import { register, t } from "/assets/app-i18n.js";
7
+ import { useLease } from "/assets/app-ctx.js";
8
+ import { api } from "./settings-confirm.js";
9
+
10
+ register("settings", {
11
+ en: {
12
+ ntButton: "Test connection",
13
+ ntRunning: "Testing… (up to about a minute)",
14
+ ntHint: "Tests the values above as filled in, saved or not — with the token typed below, or the saved one.",
15
+ ntBusy: "A test is already running. Try again when it ends.",
16
+ ntFailed: "The test could not run. Try again.",
17
+ ntNotRun: "Not tested (an earlier step failed).",
18
+ ntTimeout: "The test took too long and was stopped. Test again; if it repeats, check this machine's network.",
19
+ ntCancelled: "Your sign-in ended, so nothing was started. Sign in again and test again.",
20
+ ntCleanup: "The test's connector could not be confirmed stopped. Run agend restart before testing or opening the link again.",
21
+ ntStep_values: "The domain, port and token",
22
+ ntStep_origin: "This machine answers on port {1}",
23
+ ntStep_tunnel: "The tunnel connects to Cloudflare",
24
+ ntStep_dns: "{0} can be looked up",
25
+ ntStep_reach: "{0} reaches this AgEnD",
26
+ nt_values_ok: "Filled in correctly.",
27
+ "nt_values_bad-hostname": "Enter a domain such as agend.example.com (no https://, path or port).",
28
+ "nt_values_bad-port": "Enter a port from 1024 to 65535.",
29
+ "nt_values_no-token": "No connector token: paste it below (Cloudflare → your tunnel → the long string that starts with eyJ).",
30
+ "nt_values_bad-token": "This is not a connector token: copy only the long string that starts with eyJ.",
31
+ nt_origin_ok: "Answered for this test.",
32
+ nt_origin_live: "Your link is open on these values: it is tested as it is.",
33
+ "nt_origin_dashboard-port": "{1} is the dashboard's own port. Choose another (19282 by default), and make the Cloudflare route's service http://127.0.0.1:<that port>.",
34
+ "nt_origin_port-in-use": "Port {1} is used by another program on this machine. Choose another port here, and make the Cloudflare route's service http://127.0.0.1:<that port>.",
35
+ "nt_origin_origin-failed": "AgEnD could not listen on port {1}. Try another port.",
36
+ "nt_origin_link-open-other": "Your link is open with other settings. Close it (/web → Close public link), then test again.",
37
+ "nt_origin_tunnel-busy": "Another tunnel is starting or running (a public link or a sign-in). Try again in a minute.",
38
+ nt_tunnel_ok: "Connected to Cloudflare.",
39
+ nt_tunnel_live: "Your open link is connected.",
40
+ nt_tunnel_auth: "Cloudflare refused the token: it is wrong or was rotated. Copy the current token from the tunnel's page in Cloudflare and paste it again.",
41
+ nt_tunnel_network: "This machine could not reach Cloudflare within 20 seconds. Set the protocol to http2 here, or ask the network administrator to allow outbound connections to Cloudflare (port 7844).",
42
+ nt_tunnel_stopped: "cloudflared stopped before it connected. Test again; if it repeats, check the token and the protocol.",
43
+ "nt_tunnel_no-binary": "cloudflared could not be prepared on this machine (its download or install failed). Check this machine's internet access and test again.",
44
+ nt_dns_ok: "Found.",
45
+ "nt_dns_dns-missing": "{0} does not exist yet. In Cloudflare, add a Published application route for {0} on this tunnel.",
46
+ "nt_dns_dns-failed": "This machine could not look up {0}: this machine's network may block DNS. Ask the network administrator.",
47
+ nt_reach_ok: "It works: {0} reached this AgEnD.",
48
+ "nt_reach_wrong-service": "Cloudflare answered but could not reach the service: make the route's service http://127.0.0.1:{1} (HTTP, not HTTPS).",
49
+ "nt_reach_other-page": "{0} answered with another page: its route points to another service or tunnel. Point it to this tunnel, with the service http://127.0.0.1:{1}.",
50
+ "nt_reach_route-missing": "{0} is not routed to this tunnel. In Cloudflare, add a Published application route for {0}.",
51
+ "nt_reach_self-unreachable": "The tunnel is connected, but this machine's network can't open {0} (some networks filter new domains). From another network, such as a phone on mobile data, it may still work. Ask the network administrator to allow {0}.",
52
+ "nt_reach_reach-failed": "{0} did not answer as expected. Test again in a minute; if it repeats, check the route in Cloudflare.",
53
+ },
54
+ "zh-TW": {
55
+ ntButton: "測試連線",
56
+ ntRunning: "測試中…(最多約 1 分鐘)",
57
+ ntHint: "用上面填的設定測試(不論是否已儲存),token 用下方輸入的,沒有就用已儲存的。",
58
+ ntBusy: "已經有一個測試在進行,請等它結束再試。",
59
+ ntFailed: "測試沒有執行成功,請再試一次。",
60
+ ntNotRun: "未測試(前面的步驟失敗)。",
61
+ ntTimeout: "測試太久,已經停止。請再測一次;如果一直這樣,請檢查這台主機所在的網路。",
62
+ ntCancelled: "你的登入已結束,所以沒有啟動任何東西。請重新登入後再測。",
63
+ ntCleanup: "無法確認測試用的連接器已經停止。請先執行 agend restart,再測試或開啟連結。",
64
+ ntStep_values: "網域、port 與 token",
65
+ ntStep_origin: "這台主機在 port {1} 回應",
66
+ ntStep_tunnel: "tunnel 連上 Cloudflare",
67
+ ntStep_dns: "查得到 {0}",
68
+ ntStep_reach: "{0} 連得到這台 AgEnD",
69
+ nt_values_ok: "填寫正確。",
70
+ "nt_values_bad-hostname": "請填網域,例如 agend.example.com(不含 https://、路徑或 port)。",
71
+ "nt_values_bad-port": "請填 1024–65535 之間的 port。",
72
+ "nt_values_no-token": "沒有 connector token:請貼在下方(Cloudflare → 你的 tunnel → 以 eyJ 開頭的長字串)。",
73
+ "nt_values_bad-token": "這不是 connector token:只複製以 eyJ 開頭的那串長字串。",
74
+ nt_origin_ok: "這次測試有回應。",
75
+ nt_origin_live: "你的連結正以這些設定開著:直接測試它。",
76
+ "nt_origin_dashboard-port": "{1} 是儀表板自己的 port。請換一個(預設 19282),並把 Cloudflare 路由的 service 設成 http://127.0.0.1:<那個 port>。",
77
+ "nt_origin_port-in-use": "這台主機上有其他程式在用 port {1}。請在這裡換一個 port,並把 Cloudflare 路由的 service 改成 http://127.0.0.1:<那個 port>。",
78
+ "nt_origin_origin-failed": "AgEnD 無法在 port {1} 監聽,請換一個 port。",
79
+ "nt_origin_link-open-other": "你的連結正以其他設定開著。請先關閉(/web → 關閉公開連結),再測試。",
80
+ "nt_origin_tunnel-busy": "另一條 tunnel 正在啟動或執行(公開連結或登入用)。請過一分鐘再試。",
81
+ nt_tunnel_ok: "已連上 Cloudflare。",
82
+ nt_tunnel_live: "開著的連結已連線。",
83
+ nt_tunnel_auth: "Cloudflare 拒絕這個 token:它是錯的或已被更換。請到 Cloudflare 的 tunnel 頁面複製目前的 token 再貼上。",
84
+ nt_tunnel_network: "這台主機在 20 秒內連不上 Cloudflare。請在這裡把通訊協定改成 http2,或請網路管理員允許對 Cloudflare 的對外連線(port 7844)。",
85
+ nt_tunnel_stopped: "cloudflared 在連上之前就停止了。請再測一次;如果一直這樣,請檢查 token 與通訊協定。",
86
+ "nt_tunnel_no-binary": "這台主機無法準備 cloudflared(下載或安裝失敗)。請確認這台主機能連上網路後再測。",
87
+ nt_dns_ok: "查得到。",
88
+ "nt_dns_dns-missing": "{0} 還不存在。請在 Cloudflare 為這條 tunnel 新增 {0} 的 Published application 路由。",
89
+ "nt_dns_dns-failed": "這台主機查不到 {0}:這台主機所在的網路可能擋了 DNS。請洽網路管理員。",
90
+ nt_reach_ok: "正常:{0} 連到了這台 AgEnD。",
91
+ "nt_reach_wrong-service": "Cloudflare 有回應,但連不到服務:請把路由的 service 設成 http://127.0.0.1:{1}(HTTP,不是 HTTPS)。",
92
+ "nt_reach_other-page": "{0} 回應了別的頁面:它的路由指向別的服務或 tunnel。請把它指向這條 tunnel,service 設成 http://127.0.0.1:{1}。",
93
+ "nt_reach_route-missing": "{0} 沒有路由到這條 tunnel。請在 Cloudflare 新增 {0} 的 Published application 路由。",
94
+ "nt_reach_self-unreachable": "tunnel 已連上,但這台主機所在的網路打不開 {0}(有些網路會擋新的網域)。從其他網路(例如手機行動網路)可能可以正常使用。請網路管理員允許 {0}。",
95
+ "nt_reach_reach-failed": "{0} 沒有如預期回應。請過一分鐘再測;如果一直這樣,請檢查 Cloudflare 上的路由。",
96
+ },
97
+ });
98
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
99
+ const STEPS = ["values", "origin", "tunnel", "dns", "reach"];
100
+
101
+ /**
102
+ * The connector token typed in the token card below (not yet saved): the test uses it when there is one. The card
103
+ * owns it: it sets it as typed and clears it when saved, cleared or unmounted (#2907 review: a hidden token from a
104
+ * card no longer on screen is never used).
105
+ */
106
+ let typedToken = "";
107
+ export function setTypedTunnelToken(value) { typedToken = typeof value === "string" ? value : ""; }
108
+
109
+ /** The button and its result, for the fixed-address values the form holds now. */
110
+ export function NamedLinkTest({ hostname, port, protocol }) {
111
+ const lease = useLease("named-link-test");
112
+ const [busy, setBusy] = useState(false);
113
+ const [result, setResult] = useState(null);
114
+ const [note, setNote] = useState("");
115
+ const running = useRef(false);
116
+ const run = async () => {
117
+ if (running.current) return;
118
+ running.current = true; setBusy(true); setNote(""); setResult(null);
119
+ try {
120
+ const r = await api("/api/settings/public-link/test", { method: "POST", body: JSON.stringify({
121
+ hostname: String(hostname ?? "").trim(), origin_port: String(port ?? "").trim() || "19282", protocol, ...(typedToken.trim() ? { token: typedToken } : {}),
122
+ }) });
123
+ if (!lease.current()) return;
124
+ if (r.status === 409) setNote(tn("ntBusy"));
125
+ else if (!r.ok || !Array.isArray(r.body?.steps)) setNote(tn("ntFailed"));
126
+ else setResult(r.body);
127
+ } catch { if (lease.current()) setNote(tn("ntFailed")); }
128
+ finally { running.current = false; if (lease.current()) setBusy(false); }
129
+ };
130
+ const host = result?.hostname || String(hostname ?? "").trim() || "—", shownPort = result?.port ?? (String(port ?? "").trim() || "19282");
131
+ return html`<div class="named-test">
132
+ <div class="save-row"><button type="button" class="btn btn-sm" id="namedLinkTest" disabled=${busy} onClick=${run}>${busy ? tn("ntRunning") : tn("ntButton")}</button></div>
133
+ <p class="note">${tn("ntHint")}</p>
134
+ ${note ? html`<p class="note" role="status">${note}</p>` : null}
135
+ ${result ? html`<ol class="named-test-steps" role="status" aria-live="polite">${STEPS.map((step) => {
136
+ const r = result.steps.find((s) => s.step === step);
137
+ const mark = !r ? "–" : r.ok ? "✓" : "✗";
138
+ const said = !r ? tn("ntNotRun") : r.code === "timeout" ? tn("ntTimeout") : r.code === "cancelled" ? tn("ntCancelled") : tn(`nt_${step}_${r.code}`, host, shownPort);
139
+ return html`<li key=${step} class=${!r ? "nt-skip" : r.ok ? "nt-ok" : "nt-fail"} data-step=${step} data-code=${r ? r.code : ""}>
140
+ <span class="nt-mark" aria-hidden="true">${mark}</span> <strong>${tn(`ntStep_${step}`, host, shownPort)}</strong> — ${said}</li>`;
141
+ })}</ol>` : null}
142
+ ${result?.cleanup ? html`<p class="feedback error" role="alert">${tn("ntCleanup")}</p>` : null}
143
+ </div>`;
144
+ }
@@ -65,6 +65,7 @@ const ROWS = [
65
65
  // Fleet · Web dashboard
66
66
  ["dash.log-tab", "web-dashboard", "#webLogTab", "settings.webLogTab", "web.log_tab", ["log tab", "steps", "log"], ["Log 分頁", "紀錄", "步驟"]],
67
67
  ["dash.usage-public", "web-dashboard", "#webUsagePublic", "settings.webUsagePublic", "web.usage_report_on_public_link", ["usage", "public link", "cost", "tokens"], ["用量", "公開連結", "費用"]],
68
+ ["dash.preview-cdn", "web-dashboard", "#webPreviewCdn", "settings.webPreviewCdn", "web.preview_cdn", ["cdn", "libraries", "html preview", "chart", "jsdelivr", "cdnjs"], ["CDN", "函式庫", "HTML 預覽", "圖表"]],
68
69
  ["dash.office-levels", "web-dashboard", "#webOfficeLevels", "settings.webOfficeLevels", "web.office_levels", ["office", "level", "badge", "profile card"], ["辦公室", "等級", "徽章", "名片"]],
69
70
  ["dash.app-icon", "web-dashboard", ".s-app-icon", "settings.appIconTitle", null, ["app icon", "favicon", "home screen", "icon"], ["App 圖示", "圖示", "主畫面"]],
70
71
  ["dash.card-limits", "web-dashboard", ".s-card-limits", "settings.cardLimitsTitle", "web.appearance", ["upload limits", "portrait", "banner", "card limits"], ["上傳限制", "身分卡上傳限制"]],
@@ -40,6 +40,8 @@ register("settings", {
40
40
  webEcho: "Web echo",
41
41
  webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
42
42
  webOfficeLevels: "Office levels", webOfficeLevelsHint: "Each agent's level from its lifetime work, on its name tag in the Office and on its profile card. Off removes the badges and the card's level rows. The token counts behind a level follow the usage report's rules. Applies at once.",
43
+ webPreviewCdn: "HTML previews may load libraries from cdnjs / jsDelivr",
44
+ webPreviewCdnHint: "The page's scripts may load libraries from cdnjs (/ajax/libs/) and jsDelivr (/npm/); those requests leave this machine, so the CDN sees them, and a page could use them to send data out. Scripts, styles and fonts only — a preview still cannot fetch or post anything. Off by default; applies to the next preview started, on the public link too.",
43
45
  webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
44
46
  webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
45
47
  general: "General Settings",
@@ -470,7 +472,7 @@ register("settings", {
470
472
  cssTitle: "Custom CSS (yours only)",
471
473
  cssLead: "Applies to your screens only.",
472
474
  cssLoadFailed: "Couldn't load your custom CSS. Reload the page to try again.",
473
- cssPublicLink: "Custom CSS isn't available on a temporary public link.",
475
+ cssPublicLink: "Custom CSS isn't available over a public link.",
474
476
  cssInForce: "In force: {0}",
475
477
  cssOnTrial: "On trial: {0}. Choose Keep, or it reverts.",
476
478
  cssNone: "No custom CSS in force.",
@@ -507,9 +509,9 @@ register("settings", {
507
509
  cssError_owner_css_needs_a_signed_in_person: "Sign in in the browser to change custom CSS.",
508
510
  cssError_other: "Not saved ({0}).",
509
511
  webLead: "How the web dashboard behaves. A change that loosens access (longer sessions, the browser terminal back on or for longer, the public link) waits for a fleet admin to confirm it in chat (General topic) or on the host; one that tightens it applies at once.",
510
- webPublicRefused: "Web settings can't be changed over a temporary public link. Open the dashboard at its own address to change them.",
512
+ webPublicRefused: "Web settings can't be changed over a public link. Open the dashboard at its own address to change them.",
511
513
  webSessionTitle: "Sign-in sessions",
512
- webSessionHint: "For sign-ins at the dashboard's own address (this computer, the LAN, a tailnet). A temporary public link always keeps 4 h / 30 min. Shortening ends the sessions it now outlives; lengthening applies to new sign-ins and to an existing one's idle time on its next use. Empty = the default.",
514
+ webSessionHint: "For sign-ins at the dashboard's own address (this computer, the LAN, a tailnet). Over a public link (a temporary one or your permanent link), a session lasts at most 8 h, ends after 30 min idle and never outlives the link; an admin's also never lasts longer than these settings (a guest's follows the guest settings). Shortening ends the sessions it now outlives; lengthening applies to new sign-ins and to an existing one's idle time on its next use. Empty = the default.",
513
515
  sessionAbsolute: "Session length, hours (1–720)",
514
516
  sessionIdle: "Sign out after unused for, hours (1–720)",
515
517
  sessionRange: "Must be a number of hours from 1 to 720",
@@ -721,6 +723,8 @@ register("settings", {
721
723
  webEcho: "Web 同步",
722
724
  webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
723
725
  webOfficeLevels: "辦公室等級", webOfficeLevelsHint: "每個 agent 依終身工作量得到的等級,顯示在辦公室的名牌與名片上。關閉會移除徽章與名片上的等級列。等級背後的 token 數字依用量報告的規則顯示。變更立即生效。",
726
+ webPreviewCdn: "HTML 預覽可從 cdnjs / jsDelivr 載入函式庫",
727
+ webPreviewCdnHint: "頁面的指令碼可以從 cdnjs(/ajax/libs/)和 jsDelivr(/npm/)載入函式庫;這些請求會離開這台電腦,CDN 會看到它們,頁面也可能藉此把資料傳出去。只限指令碼、樣式與字型——預覽仍然無法擷取或送出任何資料。預設關閉;之後開始的預覽才會套用,公開連結也一樣。",
724
728
  webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
725
729
  webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
726
730
  general: "一般設定",
@@ -1124,7 +1128,7 @@ register("settings", {
1124
1128
  cssTitle: "自訂 CSS(只套用在你自己)",
1125
1129
  cssLead: "只套用在你的畫面。",
1126
1130
  cssLoadFailed: "無法載入你的自訂 CSS。請重新整理頁面再試一次。",
1127
- cssPublicLink: "臨時公開連結無法使用自訂 CSS。",
1131
+ cssPublicLink: "透過公開連結無法使用自訂 CSS。",
1128
1132
  cssInForce: "使用中:{0}",
1129
1133
  cssOnTrial: "試用中:{0}。請按「保留」,否則會還原。",
1130
1134
  cssNone: "目前沒有使用自訂 CSS。",
@@ -1161,9 +1165,9 @@ register("settings", {
1161
1165
  cssError_owner_css_needs_a_signed_in_person: "請在瀏覽器登入後再變更自訂 CSS。",
1162
1166
  cssError_other: "未儲存({0})。",
1163
1167
  webLead: "網頁儀表板的行為。放寬存取的變更(延長 session、重新開啟或延長瀏覽器終端機、公開連結)需要 fleet admin 在聊天(General topic)或主機上確認;收緊的變更會立即套用。",
1164
- webPublicRefused: "無法透過臨時公開連結變更網頁設定。請從儀表板本身的網址開啟後再變更。",
1168
+ webPublicRefused: "無法透過公開連結變更網頁設定。請從儀表板本身的網址開啟後再變更。",
1165
1169
  webSessionTitle: "登入 session",
1166
- webSessionHint: "適用於從儀表板本身網址登入的 session(本機、區域網路、tailnet)。臨時公開連結固定為 4 小時 / 30 分鐘。縮短會讓已超過新限制的 session 結束;延長會套用到新的登入,以及既有 session 下次使用時的閒置時間。留空 = 預設值。",
1170
+ webSessionHint: "適用於從儀表板本身網址登入的 session(本機、區域網路、tailnet)。透過公開連結(臨時連結或你的固定網域)的 session 最長 8 小時、閒置 30 分鐘就結束,也不會超過連結本身;管理者的 session 也不會超過這裡的設定(訪客依訪客設定)。縮短會讓已超過新限制的 session 結束;延長會套用到新的登入,以及既有 session 下次使用時的閒置時間。留空 = 預設值。",
1167
1171
  sessionAbsolute: "Session 時長,小時(1–720)",
1168
1172
  sessionIdle: "閒置多久後登出,小時(1–720)",
1169
1173
  sessionRange: "必須是 1 到 720 之間的小時數",
@@ -105,7 +105,7 @@ register("app", {
105
105
  restartRefused: "Restart refused: {0}",
106
106
  pendingSent: "Sent for confirmation — a fleet admin confirms it in chat.",
107
107
  pendingSelfSent: "Confirmed by your account (this browser is web-verified) — applying.",
108
- needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting",
108
+ needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting", noticesNav: "Notices", noticesCount: "{0} unread",
109
109
  // #2293: global search — chats, cross-instance messages and tasks (not the sidebar's filter of agents).
110
110
  search: "Search", searchTitle: "Search chats, messages between agents and tasks ({0})",
111
111
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
@@ -152,7 +152,7 @@ register("app", {
152
152
  restartRefused: "重新啟動被拒絕:{0}",
153
153
  pendingSent: "已送出確認——由 fleet 管理員在聊天中確認。",
154
154
  pendingSelfSent: "已由你的帳號確認(這個瀏覽器已通過網頁驗證)——套用中。",
155
- needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理",
155
+ needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理", noticesNav: "通知", noticesCount: "{0} 則未讀",
156
156
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
157
157
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
158
158
  sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
@@ -30,6 +30,7 @@ export function parseRoute(pathname) {
30
30
  if (pathname === "/ui") return { panel: "chat", instance: null };
31
31
  if (pathname === "/ui/fleet") return { panel: "fleet", tab: "tasks" };
32
32
  if (pathname === "/ui/needs") return { panel: "needs" };
33
+ if (pathname === NOTICES_PATH) return { panel: "notices" }; // #2910 D2: a web-only fleet's Notices
33
34
  // #1523 N2: one instance's Details (`agent` is no Fleet tab; a tab is one segment).
34
35
  let m = /^\/ui\/fleet\/agent\/([^/]+)$/.exec(pathname);
35
36
  if (m) return named("details", m[1]);
@@ -82,6 +83,7 @@ export function rememberFleetTab(tab) {
82
83
  try { localStorage.setItem(LAST_FLEET_TAB_KEY, tab); } catch { /* this page only */ }
83
84
  }
84
85
  export const NEEDS_PATH = "/ui/needs";
86
+ export const NOTICES_PATH = "/ui/notices";
85
87
  /** A section's address; an old name gives its new one (and the place in it: `chat#classicbot`). */
86
88
  export function settingsPath(name) {
87
89
  const section = (name && legacySection(name)) || name;
@@ -109,6 +111,7 @@ export function routePath(route) {
109
111
  if (route.panel === "fleet") return fleetPath(route.tab);
110
112
  if (route.panel === "settings") return settingsPath(route.section);
111
113
  if (route.panel === "needs") return NEEDS_PATH;
114
+ if (route.panel === "notices") return NOTICES_PATH;
112
115
  if (route.panel === "view") return viewPath(route.instance);
113
116
  if (route.panel === "details") return detailsPath(route.instance);
114
117
  if (route.panel === "log") return logPath(route.instance);
@@ -140,6 +143,7 @@ export function textLinkSegments(text, links) {
140
143
  export function routeKey(route) {
141
144
  if (!route) return "none";
142
145
  if (route.panel === "needs") return "needs:";
146
+ if (route.panel === "notices") return "notices:";
143
147
  return `${route.panel}:${route.panel === "fleet" ? route.tab : route.panel === "settings" ? route.section : route.instance ?? ""}`;
144
148
  }
145
149
 
@@ -10,7 +10,7 @@ import { t, lang, setLang, onLang } from "./app-i18n.js";
10
10
  import { appStore, createStore, guestBadge, guestPage, guestTrusted, useStore } from "./app-store.js";
11
11
  import { navStore } from "./app-nav.js";
12
12
  import { toast } from "./ui-toast.js";
13
- import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, fleetEntryPath } from "./app-route.js";
13
+ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, NOTICES_PATH, fleetEntryPath } from "./app-route.js";
14
14
  import { SessionMenu } from "./app-session.js";
15
15
  import { onPrefsChanged, savePref } from "./app-prefs.js";
16
16
  import { applyPageTheme, pageNow, resolvedMode, themesStore } from "./app-themes.js";
@@ -328,8 +328,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
328
328
  const canCreate = useCanCreate();
329
329
  const on = (panel) => !!route && route.panel === panel;
330
330
  // A count badge is hidden at 0; its number is also in the link's accessible name.
331
- const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
332
- aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t("app.needsCount", count)})` : undefined} onClick=${closeDrawer}>
331
+ const navLink = (panel, href, icon, label, count = 0, countText = "app.needsCount") => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
332
+ aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t(countText, count)})` : undefined} onClick=${closeDrawer}>
333
333
  <${Icon} name=${icon} /><span class="grow">${label}</span>${count ? html`<span class="nav-count" aria-hidden="true">${count}</span>` : null}</a>`;
334
334
  const needsCount = Array.isArray(app.needs) ? app.needs.length : 0;
335
335
  // View-only (an anonymous reader, #1408 §3): View and a way to sign in — nothing that needs a session is rendered.
@@ -379,6 +379,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
379
379
  <nav data-trust="navigation" class="side-nav" aria-label=${t("app.menu")}>
380
380
  ${overview}
381
381
  ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
382
+ ${/* #2910 D2: a web-only fleet (no chat connection) has Notices — what General would have said. */
383
+ app.connections === 0 && app.notices && app.notices.enabled ? navLink("notices", NOTICES_PATH, "bell", t("app.noticesNav"), app.notices.unread | 0, "app.noticesCount") : null}
382
384
  ${searchOffered(canCreate) ? searchRow() : null}
383
385
  ${canCreate && onNewInstance ? html`<button type="button" class="side-row side-new" onClick=${onNewInstance}><${Icon} name="plus" /><span class="grow">${t("app.newAgent")}</span></button>` : null}
384
386
  </nav>
@@ -63,7 +63,77 @@ export function applyStatus(d) {
63
63
  // #1685: host memory pressure slowing or holding new agents ("elevated" | "critical"), else null.
64
64
  hostMemory: d.host_memory === "elevated" || d.host_memory === "critical" ? d.host_memory : null,
65
65
  // #2015: while it lasts, the largest processes in the fleet and whose they are (at most 3; anything else dropped).
66
- hostMemoryTop: hostMemoryTop(d.host_memory_top) });
66
+ hostMemoryTop: hostMemoryTop(d.host_memory_top),
67
+ // #2910 D2 (#2925 review): the newest Notice on a web-only fleet — a polling page reads the list again when it moves.
68
+ noticesNewest: Number.isSafeInteger(d.notices_newest) ? d.notices_newest : null });
69
+ }
70
+
71
+ /**
72
+ * #2910 D2 (#2925 review): a web-only fleet's Notices as the page keeps them. A list read (GET /ui/notices/list), a live
73
+ * one (SSE `notice`) and a read mark (POST /ui/notices/seen) can answer in any order, so each is merged, never
74
+ * replaced: the newest NOTICES_KEPT by id (a list read never drops one that arrived live after it was taken), and the
75
+ * read mark only moves forward. Unread is counted here from the two.
76
+ */
77
+ export const NOTICES_KEPT = 500;
78
+ function noticesState(enabled, byId, seen) {
79
+ const items = [...byId.values()].sort((a, b) => b.id - a.id).slice(0, NOTICES_KEPT);
80
+ return { enabled, items, seen, unread: items.filter((n) => n.id > seen).length };
81
+ }
82
+ const validNotice = (n) => !!n && Number.isSafeInteger(n.id) && n.id > 0 && typeof n.text === "string";
83
+ export function mergeNoticeList(cur, d) {
84
+ const byId = new Map();
85
+ for (const n of Array.isArray(d && d.items) ? d.items : []) if (validNotice(n)) byId.set(n.id, n);
86
+ const listNewest = Math.max(0, ...byId.keys());
87
+ for (const n of cur && Array.isArray(cur.items) ? cur.items : []) if (n.id > listNewest && !byId.has(n.id)) byId.set(n.id, n);
88
+ return noticesState(!!(d && d.enabled === true), byId, Math.max(cur ? cur.seen | 0 : 0, d ? d.seen | 0 : 0));
89
+ }
90
+ export function addNotice(cur, n) {
91
+ if (!cur || !cur.enabled || !validNotice(n) || cur.items.some((x) => x.id === n.id)) return cur;
92
+ return noticesState(true, new Map([...cur.items, n].map((x) => [x.id, x])), cur.seen | 0);
93
+ }
94
+ /** The newest Notice the status frame names that the page does not have yet (a polling page hears no `notice`). 0: none. */
95
+ export function noticesCatchUp(s) {
96
+ const have = s && s.notices && s.notices.items.length ? s.notices.items[0].id : 0;
97
+ return s && Number.isSafeInteger(s.noticesNewest) && s.noticesNewest > have ? s.noticesNewest : 0;
98
+ }
99
+ /** After a read that left the page still behind: try again this soon (a failed read waits longer). */
100
+ export const NOTICES_RETRY_MS = { stale: 1000, failed: 5000 };
101
+ /**
102
+ * #2910 D2 (#2925 review r2): the page's reads of GET /ui/notices/list. One at a time (a second ask joins the read in
103
+ * flight); once a read settles, the page asks again if it is still behind what the status frame names (an older read
104
+ * that was already in flight, or one that failed) — after NOTICES_RETRY_MS, and again until it catches up. Never a
105
+ * watermark set before a read succeeded.
106
+ */
107
+ export function createNoticesSync({ store, fetchList, setTimer = (fn, ms) => setTimeout(fn, ms), now = () => Date.now() }) {
108
+ let inFlight = null, retryAt = 0, timer = null, on = false;
109
+ const behind = () => on && noticesCatchUp(store.get()) > 0;
110
+ const recheck = () => { if (!inFlight && behind() && now() >= retryAt) read(); };
111
+ const later = (ms) => {
112
+ retryAt = now() + ms;
113
+ if (timer === null) timer = setTimer(() => { timer = null; recheck(); }, ms);
114
+ };
115
+ function read() {
116
+ return (inFlight ??= Promise.resolve().then(fetchList).then((d) => {
117
+ if (!d || !Array.isArray(d.items)) throw new Error("no list");
118
+ store.set({ notices: mergeNoticeList(store.get().notices, d) });
119
+ return true;
120
+ }).catch(() => false).then((ok) => {
121
+ inFlight = null;
122
+ if (behind()) later(ok ? NOTICES_RETRY_MS.stale : NOTICES_RETRY_MS.failed);
123
+ else retryAt = 0;
124
+ }));
125
+ }
126
+ return {
127
+ /** The fleet is web-only: start (the first read), and follow the status frame from now on. */
128
+ start() { if (!on) { on = true; read(); } },
129
+ read,
130
+ recheck,
131
+ started: () => on,
132
+ };
133
+ }
134
+ export function applyNoticesSeen(cur, seen) {
135
+ if (!cur || !Number.isSafeInteger(seen) || seen <= (cur.seen | 0)) return cur;
136
+ return noticesState(cur.enabled, new Map(cur.items.map((x) => [x.id, x])), seen);
67
137
  }
68
138
 
69
139
  /** #2015: the status frame's host_memory_top, as the dashboard keeps it: well-formed entries only, at most 3. */
@@ -37,7 +37,7 @@
37
37
  // offices — the stored offices changed ({ rev }; SSE only: the Office view reads /ui/offices again).
38
38
  // office_skins — a skin pack was uploaded or deleted ({ rev }; SSE only: the Office view reads /ui/office-skins again).
39
39
  // office_acts — the agents' live acts in the Office view changed ({ acts }, #2578; a poll page reads /ui/office-acts).
40
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts", "office_pack_changes"];
40
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts", "office_pack_changes", "notice"];
41
41
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
42
42
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
43
43
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -807,6 +807,39 @@ img.amb-p { object-fit: contain; }
807
807
  .preview-frame.fill { flex: 1; height: 100%; min-height: 0; }
808
808
  .pv-code { flex: 1; min-height: 0; margin: 0; overflow: auto; font-family: var(--font-mono); font-size: var(--fs-xs); line-height: 1.5;
809
809
  background: var(--surface); border-radius: var(--r-sm); padding: var(--s-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
810
+ /* preview panel chrome (M-2216) — #2911: the version step and the Changes view. */
811
+ .pv-ver { display: inline-flex; align-items: center; gap: var(--s-1); font-size: var(--fs-xs); color: var(--text-2); }
812
+ .pv-ver-at { min-width: 6ch; text-align: center; font-variant-numeric: tabular-nums; }
813
+ .pv-panel > .pv-ver-note { margin: 0 var(--s-3) var(--s-2); }
814
+ .pv-diff-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: var(--s-2); }
815
+ .pv-diff-head { font-size: var(--fs-xs); color: var(--text-2); }
816
+ .pv-diff { white-space: pre; overflow-wrap: normal; }
817
+ .pv-diff > code > span { display: block; }
818
+ .pv-diff .d-add { background: var(--ok-bg); color: var(--ok); }
819
+ .pv-diff .d-del { background: var(--danger-bg); color: var(--danger); }
820
+ .pv-diff .d-hunk { color: var(--text-3); }
821
+ .pv-diff-note { margin: 0; }
822
+ /* #2911 (2): the page at a phone's or a tablet's width, centred; a panel narrower than that scrolls sideways. */
823
+ .pv-panel-body.w-phone, .pv-panel-body.w-tablet { overflow-x: auto; }
824
+ /* The frame keeps its 2px warning border (.preview-frame): the holder is that much wider, so the page itself lays out
825
+ at exactly 390 / 768 CSS px (its innerWidth, its min-width media queries) — #2918 review. */
826
+ .pv-panel-body.w-phone .pv-holder { flex: 1 0 auto; width: calc(390px + 2 * 2px); max-width: none; margin-inline: auto; }
827
+ .pv-panel-body.w-tablet .pv-holder { flex: 1 0 auto; width: calc(768px + 2 * 2px); max-width: none; margin-inline: auto; }
828
+ .pv-width { font: inherit; font-size: var(--fs-xs); padding: 2px var(--s-1); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg); color: var(--text); }
829
+ /* Full screen: the API's (:fullscreen), or the panel over the whole window where the page may not use it. */
830
+ /* Three classes, so it wins over the desktop split's ".chat-split > .pv-panel { position: relative; z-index: 1 }" — #2918 review. */
831
+ .pv-panel.pv-fs.pv-fs-native, .pv-panel.pv-fs.pv-fs-immersive { width: auto; min-width: 0; max-width: none; }
832
+ .chat-split > .pv-panel.pv-fs-immersive, .pv-panel.pv-fs.pv-fs-immersive { position: fixed; inset: 0; z-index: 60; }
833
+ /* #2911 (6): the thread's pages. */
834
+ .pv-pages { list-style: none; margin: 0; padding: 0; overflow: auto; display: flex; flex-direction: column; gap: var(--s-1); }
835
+ .pv-page { width: 100%; display: flex; align-items: center; gap: var(--s-2); padding: var(--s-2) var(--s-3); border: 1px solid var(--border);
836
+ border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer; }
837
+ .pv-page.on { border-color: var(--accent); }
838
+ .pv-page-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
839
+ .pv-page-meta { flex: none; color: var(--text-3); font-size: var(--fs-xs); }
840
+ .pv-pages-toggle { flex: none; }
841
+ /* A narrow panel: two rows — versions and views; then the width, the run controls, the page's actions and Download. */
842
+ @container (max-width: 560px) { .pv-panel-bar .grow { flex-basis: 100%; height: 0; } }
810
843
  .msg.flash { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--r-sm); transition: outline-color var(--t-med); }
811
844
  /* #2272: a message's reply action (top right, on hover or focus), the quote a reply shows, the message menu, a swipe. */
812
845
  .msg[data-key] { position: relative; }
@@ -837,6 +870,10 @@ img.amb-p { object-fit: contain; }
837
870
  @container (max-width: 560px) {
838
871
  .pv-panel-bar :not(.pv-panel-run) > .lbl { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
839
872
  }
873
+ /* preview feedback (M-2217, #2911): the errors chip — its count stays when a narrow bar hides the word. */
874
+ .pv-fb-errors { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
875
+ .pv-fb-pick.on { color: var(--warn); border-color: var(--warn); }
876
+ .pv-fb-errors .pv-fb-n { font-variant-numeric: tabular-nums; font-weight: var(--fw-semibold); }
840
877
  /* #1266: an agent reply's buttons — choices under the text; one answers for everyone. */
841
878
  .rb-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); margin-top: var(--s-2); }
842
879
  .rb-btn { max-width: 100%; white-space: normal; text-align: left; }
@@ -935,6 +972,13 @@ img.amb-p { object-fit: contain; }
935
972
  .webauth-tip[hidden] { display: none; }
936
973
  /* #1920 phase 3 / #2348: Settings → Access & security → Guests. The fixed template (#2330): flat rows split by a rule,
937
974
  no rounded box or fill; an edited row is .s-dirty (the shared straight bar + tint, square). A state is coloured text. */
975
+ /* #2906: the fixed address's Test connection — one row per step, ✓ / ✗ / – and what to do next. */
976
+ .named-test-steps { list-style: none; margin: var(--s-2) 0 0; padding: 0; display: grid; gap: var(--s-1); font-size: var(--fs-sm); }
977
+ .named-test-steps li { overflow-wrap: anywhere; }
978
+ .named-test-steps .nt-mark { display: inline-block; width: 1.2em; font-weight: var(--fw-semibold); }
979
+ .named-test-steps .nt-ok .nt-mark { color: var(--ok); }
980
+ .named-test-steps .nt-fail .nt-mark { color: var(--danger); }
981
+ .named-test-steps .nt-skip { color: var(--text-3); }
938
982
  .s-guests .guest-names { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 2px var(--s-3); max-height: 220px; overflow: auto;
939
983
  padding: var(--s-1) 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
940
984
  .s-guests .guest-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1588,6 +1632,11 @@ textarea.s-yaml { white-space: pre; overflow-x: auto; }
1588
1632
  .n-detail { color: var(--text-2); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; }
1589
1633
  .n-actions { display: flex; gap: var(--s-2); flex-wrap: wrap; margin-top: var(--s-1); }
1590
1634
  .n-notify { display: flex; flex-direction: column; gap: var(--s-1); }
1635
+ /* #2910 D2: Notices (a web-only fleet's General) — the Needs you rows; a new one has an accent edge and a "New" tag. */
1636
+ .n-item.notice.unread { box-shadow: inset 3px 0 0 var(--accent); }
1637
+ .n-item.notice:not(.bad) .n-icon { color: var(--text-2); }
1638
+ .notice-new { font-size: var(--fs-xs); color: var(--accent); font-weight: var(--fw-semibold); }
1639
+ .notice-sub { margin: 0 var(--s-1) var(--s-3); }
1591
1640
  .confirm-text { white-space: pre-wrap; font-size: var(--fs-sm); color: var(--text-2); }
1592
1641
 
1593
1642
  /* ── Org chart (#1389): General → teams → instances. Connectors are borders and pseudo-elements; no inline style.
@@ -2235,7 +2284,7 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2235
2284
  @media (min-width: 900px) { .chat-split > .pv-divider, .chat-split > .pv-panel { position: relative; z-index: 1; } }
2236
2285
  /* #1629: full-app bounded stock surfaces. Panel roots stay neutral; effects never raise the whole app. */
2237
2286
  .p-fleet > .seg, .p-settings > .seg,
2238
- .p-fleet > .panel-body, .p-needs > .panel-body, .p-settings > .panel-body,
2287
+ .p-fleet > .panel-body, .p-needs > .panel-body, .p-notices > .panel-body, .p-settings > .panel-body,
2239
2288
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
2240
2289
  .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2241
2290
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
@@ -13,7 +13,7 @@ import { installSwipe } from "./app-swipe.js";
13
13
  import { installTextUndo } from "./text-undo.js";
14
14
  import { startRouter, navStore, navigate } from "./app-nav.js";
15
15
  import { createStream } from "./app-stream.js";
16
- import { appStore, applyStatus, applyActivity, guestPage, guestTrusted } from "./app-store.js";
16
+ import { appStore, applyStatus, applyActivity, guestPage, guestTrusted, addNotice, createNoticesSync } from "./app-store.js";
17
17
  import { chatPath } from "./app-route.js";
18
18
  import { startNeedsNotifier } from "./app-needs.js";
19
19
  import { toast } from "./ui-toast.js";
@@ -91,6 +91,27 @@ if (mode === "full") {
91
91
  const loadApply = retryable((a) => import(retryUrl("/ui/js/settings-apply.js", a)));
92
92
  appStore.set({ restartRunner: (jobId) => loadApply().then((m) => m.restartPendingChanges(jobId)) });
93
93
  panels.set("needs", { load: () => loadNeeds().then((m) => m.NeedsPanel) });
94
+ // #2910 D2: a web-only fleet's Notices (/ui/notices): read once the status says there is no chat connection (and
95
+ // again after a reconnect: a poll page hears no `notice`), then each new one from the stream.
96
+ const loadNotices = retryable((a) => import(retryUrl("/ui/js/panel-notices.js", a)));
97
+ panels.set("notices", { load: () => loadNotices().then((m) => m.NoticesPanel) });
98
+ // A list read and a live notice can cross: each is merged into what the page has (app-store.js mergeNoticeList); a
99
+ // read that leaves the page behind what the status frame names is asked again (createNoticesSync).
100
+ const notices = createNoticesSync({ store: appStore,
101
+ fetchList: () => fetch("/ui/notices/list", { credentials: "same-origin", cache: "no-store" }).then((r) => (r.ok ? r.json() : null)) });
102
+ const readNotices = () => notices.read();
103
+ appStore.set({ readNotices });
104
+ appStore.subscribe((s) => {
105
+ if (s.connections === 0) notices.start();
106
+ notices.recheck(); // a polling page hears no `notice`: the status frame's newest id is its catch-up
107
+ });
108
+ stream.on("connection", (c) => { if ((c === "live" || c === "polling") && notices.started()) readNotices(); });
109
+ stream.on("notice", (d) => {
110
+ const cur = appStore.get().notices;
111
+ if (!cur || !cur.enabled) { readNotices(); return; }
112
+ const next = addNotice(cur, d && d.notice);
113
+ if (next !== cur) appStore.set({ notices: next });
114
+ });
94
115
  // #1523 N2: one instance's Details (/ui/fleet/agent/<name>).
95
116
  panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
96
117
  // #1218: one instance's Log (/ui/log/<name>).
@@ -23,6 +23,10 @@
23
23
  resend: "Send me a new code", resent: "A new code was sent to you privately in chat. Enter it above.",
24
24
  resend_wait: "A code was asked for a moment ago — wait a little and try again.",
25
25
  resend_refused: "A new code could not be sent. Ask for one with /web.",
26
+ // #2910 D5: a web-only fleet (no chat connection): there is no /web to send — the code comes from the AgEnD computer.
27
+ hint_web: "On the AgEnD computer, run agend web --code to get a sign-in code.",
28
+ refused_web: "The code is invalid or no longer works. Run agend web --code on the AgEnD computer for a new one.",
29
+ resend_refused_web: "A new code could not be sent. Run agend web --code on the AgEnD computer for one.",
26
30
  },
27
31
  "zh-TW": {
28
32
  checking: "正在確認登入狀態…", title: "登入 AgEnD", lead: "輸入登入碼。",
@@ -37,10 +41,17 @@
37
41
  resend: "傳一組新的登入碼給我", resent: "新的登入碼已私訊給你,請在上方輸入。",
38
42
  resend_wait: "剛剛才要過登入碼,請稍候再試。",
39
43
  resend_refused: "無法傳送新的登入碼。請用 /web 再要一個。",
44
+ hint_web: "在 AgEnD 電腦上執行 agend web --code 取得登入碼。",
45
+ refused_web: "登入碼無效或已失效。請在 AgEnD 電腦上執行 agend web --code 取得新碼。",
46
+ resend_refused_web: "無法傳送新的登入碼。請在 AgEnD 電腦上執行 agend web --code 取得。",
40
47
  },
41
48
  };
42
49
  const lang = /^zh/i.test(navigator.language || "") ? "zh-TW" : "en";
43
- const t = (k) => T[lang][k] || T.en[k] || k;
50
+ // #2910 D5: the server marks a web-only fleet's page (<body data-web-only="1">): its own words where it has them.
51
+ const webOnly = !!document.body && document.body.getAttribute("data-web-only") === "1";
52
+ const t = (k) => (webOnly && (T[lang][`${k}_web`] || T.en[`${k}_web`])) || T[lang][k] || T.en[k] || k;
53
+ // …and no "No chat bot?" help: the hint above already is it.
54
+ if (webOnly) document.querySelectorAll("details.help").forEach((d) => { if (d.querySelector && d.querySelector('[data-i18n="nobot"]')) d.hidden = true; });
44
55
  document.documentElement.lang = lang === "zh-TW" ? "zh-Hant" : "en";
45
56
 
46
57
  // An old dashboard link carries `?token=`. It is not a credential any more (the server ignored it and
@@ -60,6 +60,9 @@ const P = {
60
60
  panel: () => html`<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M14 4v16"/>`,
61
61
  back: () => html`<path d="M19 12H5M12 19l-7-7 7-7"/>`,
62
62
  forward: () => html`<path d="M5 12h14M12 5l7 7-7 7"/>`,
63
+ prev: () => html`<path d="M15 18l-6-6 6-6"/>`, // #2911: the preview panel's version step
64
+ next: () => html`<path d="M9 18l6-6-6-6"/>`,
65
+ diff: () => html`<path d="M12 3v8M8 7h8M8 17h8M5 21h14"/>`, // #2911: the preview panel's Changes
63
66
  // #1629: Settings → Appearance.
64
67
  palette: () => html`<path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.5-.7-.5-1.2 0-.9.8-1.7 1.7-1.7H16a5 5 0 0 0 5-5c0-4-4-7.2-9-7.2z"/><circle cx="7.5" cy="10.5" r="1"/><circle cx="10.5" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>`,
65
68
  book: () => html`<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>`,
@@ -15,10 +15,11 @@ register("webauth", {
15
15
  reason_disabled: "Web authority is turned off.",
16
16
  reason_loopback_only: "Only this computer can authorize, and this browser is not on it.",
17
17
  reason_no_account: "This browser was not signed in as an account (agend web): sign in with /web to authorize.",
18
+ reason_host_sign_in: "To speak as this computer's admin, sign in again on this computer with agend web --code.",
18
19
  reason_not_admin: "Your account is not a fleet admin here.",
19
20
  reason_not_admin_session: "Guests can't authorize.",
20
21
  reason_token_rotated: "The dashboard's token changed: sign in again.",
21
- reason_public_link: "Not on the temporary public link.",
22
+ reason_public_link: "This sign-in can't be authorized over this link.",
22
23
  reason_named_link_off: "Over your named link: turn on \"Also over my named link\" in Settings → Access & security on this computer.",
23
24
  reason_quick_link_off: "Over the temporary public link: turn on \"Also over the temporary public link\" in Settings → Access & security on this computer.",
24
25
  reason_not_session: "Sign in to authorize.",
@@ -43,10 +44,11 @@ register("webauth", {
43
44
  reason_disabled: "網頁授權已關閉。",
44
45
  reason_loopback_only: "只有這台電腦可以授權,而這個瀏覽器不在這台電腦上。",
45
46
  reason_no_account: "這個瀏覽器不是用帳號登入的(agend web):請用 /web 登入後再授權。",
47
+ reason_host_sign_in: "要以這台電腦的管理者身分發言,請在這台電腦上執行 agend web --code 重新登入。",
46
48
  reason_not_admin: "你的帳號在這裡不是 fleet 管理者。",
47
49
  reason_not_admin_session: "訪客無法授權。",
48
50
  reason_token_rotated: "儀表板的 token 已變更:請重新登入。",
49
- reason_public_link: "臨時公開連結上無法授權。",
51
+ reason_public_link: "這個登入無法透過這個連結授權。",
50
52
  reason_named_link_off: "透過固定網域:請在這台電腦的 設定 → 存取與安全 開啟「也允許透過我的固定網域」。",
51
53
  reason_quick_link_off: "透過臨時公開連結:請在這台電腦的 設定 → 存取與安全 開啟「也允許透過臨時公開連結」。",
52
54
  reason_not_session: "登入後才能授權。",
@@ -66,7 +68,7 @@ register("webauth", {
66
68
  const tn = (k, ...v) => t(`webauth.${k}`, ...v);
67
69
  // The switch and "Only this computer" are changed in Settings on this computer — never over the public link (#2525 review).
68
70
  const fixable = (reason) => (reason === "disabled" || reason === "loopback_only") && globalThis.document?.body?.dataset?.publicLink !== "1";
69
- const REASONS = ["disabled", "loopback_only", "no_account", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "quick_link_off", "not_session"];
71
+ const REASONS = ["disabled", "loopback_only", "no_account", "host_sign_in", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "quick_link_off", "not_session"];
70
72
  // Until when: the time, with the day when it isn't today (an authorization lasts up to 48 h).
71
73
  const clock = (ms) => {
72
74
  const d = new Date(ms), today = new Date().toDateString() === d.toDateString();