@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.4

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 (189) hide show
  1. package/dist/agent-whats-new.js +2 -0
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/auth-api.d.ts +30 -1
  4. package/dist/auth-api.js +42 -12
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +2 -2
  7. package/dist/backend/claude-code.js +5 -4
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/types.d.ts +6 -0
  10. package/dist/backend/types.js +15 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/backend-availability.d.ts +120 -0
  13. package/dist/backend-availability.js +396 -0
  14. package/dist/backend-availability.js.map +1 -0
  15. package/dist/backend-command.d.ts +21 -3
  16. package/dist/backend-command.js +23 -4
  17. package/dist/backend-command.js.map +1 -1
  18. package/dist/channel/adapters/discord.d.ts +7 -0
  19. package/dist/channel/adapters/discord.js +35 -0
  20. package/dist/channel/adapters/discord.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +8 -4
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +6 -0
  24. package/dist/classic-channel-manager.d.ts +8 -3
  25. package/dist/classic-channel-manager.js +10 -3
  26. package/dist/classic-channel-manager.js.map +1 -1
  27. package/dist/config-validator.js +73 -2
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/delivery-outbox.js +5 -0
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-context.d.ts +10 -0
  32. package/dist/fleet-manager.d.ts +238 -5
  33. package/dist/fleet-manager.js +878 -71
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/fleet-traffic.d.ts +38 -0
  36. package/dist/fleet-traffic.js +96 -0
  37. package/dist/fleet-traffic.js.map +1 -0
  38. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  39. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  40. package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
  41. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  42. package/dist/human-delivery-receipts.js +2 -1
  43. package/dist/human-delivery-receipts.js.map +1 -1
  44. package/dist/locale.js +48 -8
  45. package/dist/locale.js.map +1 -1
  46. package/dist/memory-hogs.d.ts +27 -0
  47. package/dist/memory-hogs.js +124 -0
  48. package/dist/memory-hogs.js.map +1 -0
  49. package/dist/outbound-handlers.d.ts +2 -0
  50. package/dist/outbound-handlers.js +40 -1
  51. package/dist/outbound-handlers.js.map +1 -1
  52. package/dist/outbound-schemas.d.ts +92 -0
  53. package/dist/outbound-schemas.js +55 -1
  54. package/dist/outbound-schemas.js.map +1 -1
  55. package/dist/public-web-gateway.js +1 -1
  56. package/dist/public-web-gateway.js.map +1 -1
  57. package/dist/public-web-link.d.ts +9 -0
  58. package/dist/public-web-link.js +8 -0
  59. package/dist/public-web-link.js.map +1 -1
  60. package/dist/quickstart-api.d.ts +10 -1
  61. package/dist/quickstart-api.js +12 -4
  62. package/dist/quickstart-api.js.map +1 -1
  63. package/dist/reply-buttons-controller.d.ts +2 -0
  64. package/dist/reply-buttons-controller.js +1 -1
  65. package/dist/reply-buttons-controller.js.map +1 -1
  66. package/dist/tmux-manager.d.ts +5 -0
  67. package/dist/tmux-manager.js +16 -0
  68. package/dist/tmux-manager.js.map +1 -1
  69. package/dist/tool-permissions.js +4 -0
  70. package/dist/tool-permissions.js.map +1 -1
  71. package/dist/topic-commands.d.ts +5 -0
  72. package/dist/topic-commands.js +16 -0
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/types.d.ts +25 -1
  75. package/dist/ui/app-effects.js +33 -9
  76. package/dist/ui/chat-effects.js +62 -4
  77. package/dist/ui/chat-strings.js +40 -4
  78. package/dist/ui/chat-thread.js +17 -6
  79. package/dist/ui/effect-trial-panel.js +19 -16
  80. package/dist/ui/fleet-canvas-layout.js +111 -0
  81. package/dist/ui/fleet-canvas.js +489 -0
  82. package/dist/ui/fleet-org.js +27 -12
  83. package/dist/ui/fleet-traffic.js +224 -0
  84. package/dist/ui/panel-chat.js +12 -5
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +32 -11
  87. package/dist/ui/panel-settings.js +6 -4
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-catalog.js +89 -0
  91. package/dist/ui/settings-dialogs.js +40 -12
  92. package/dist/ui/settings-effects.js +4 -3
  93. package/dist/ui/settings-learn.js +25 -0
  94. package/dist/ui/settings-model.js +6 -2
  95. package/dist/ui/settings-mods.js +4 -3
  96. package/dist/ui/settings-owner-effects.js +8 -8
  97. package/dist/ui/settings-strings.js +40 -38
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-prefs.js +1 -1
  100. package/dist/ui/shared/app-shell.js +23 -1
  101. package/dist/ui/shared/app-store.js +12 -1
  102. package/dist/ui/shared/app-stream.js +2 -1
  103. package/dist/ui/shared/app.css +186 -11
  104. package/dist/ui/shared/card-strings.js +4 -0
  105. package/dist/ui/shared/effect-app-runtime.js +1 -1
  106. package/dist/ui/shared/effect-frame.js +10 -5
  107. package/dist/ui/shared/effect-geometry.js +24 -9
  108. package/dist/ui/shared/effect-policy.js +2 -1
  109. package/dist/ui/shared/effect-registry.js +71 -17
  110. package/dist/ui/shared/effect-stock-trust.js +23 -2
  111. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  112. package/dist/ui/shared/effect-trial-stage.js +15 -9
  113. package/dist/ui/shared/effect-trial.css +40 -0
  114. package/dist/ui/shared/identity-card.js +75 -4
  115. package/dist/ui/shared/identity-color.js +3 -3
  116. package/dist/ui/shared/instance-nav.js +20 -4
  117. package/dist/ui/shared/panel-view.js +24 -4
  118. package/dist/ui/shared/view-strings.js +22 -0
  119. package/dist/view-api.d.ts +32 -0
  120. package/dist/view-api.js +55 -4
  121. package/dist/view-api.js.map +1 -1
  122. package/dist/web-api.d.ts +19 -4
  123. package/dist/web-api.js +178 -20
  124. package/dist/web-api.js.map +1 -1
  125. package/dist/web-appearance-policy.d.ts +4 -6
  126. package/dist/web-appearance-policy.js +5 -12
  127. package/dist/web-appearance-policy.js.map +1 -1
  128. package/dist/web-appearance-tools.js +5 -1
  129. package/dist/web-appearance-tools.js.map +1 -1
  130. package/dist/web-appearance-view.d.ts +14 -1
  131. package/dist/web-appearance-view.js +98 -10
  132. package/dist/web-appearance-view.js.map +1 -1
  133. package/dist/web-appearance.d.ts +2 -2
  134. package/dist/web-appearance.js +5 -6
  135. package/dist/web-appearance.js.map +1 -1
  136. package/dist/web-auth.js +1 -1
  137. package/dist/web-auth.js.map +1 -1
  138. package/dist/web-card.d.ts +14 -0
  139. package/dist/web-card.js +40 -6
  140. package/dist/web-card.js.map +1 -1
  141. package/dist/web-channel-echo.d.ts +7 -1
  142. package/dist/web-channel-echo.js +6 -2
  143. package/dist/web-channel-echo.js.map +1 -1
  144. package/dist/web-chat-history.d.ts +40 -0
  145. package/dist/web-chat-history.js +66 -3
  146. package/dist/web-chat-history.js.map +1 -1
  147. package/dist/web-commands.d.ts +1 -0
  148. package/dist/web-effect-tools.js +2 -3
  149. package/dist/web-effect-tools.js.map +1 -1
  150. package/dist/web-effect-trials.d.ts +2 -2
  151. package/dist/web-effect-trials.js +3 -3
  152. package/dist/web-effect-trials.js.map +1 -1
  153. package/dist/web-guest-access.d.ts +125 -0
  154. package/dist/web-guest-access.js +232 -0
  155. package/dist/web-guest-access.js.map +1 -0
  156. package/dist/web-guest-rooms.d.ts +82 -0
  157. package/dist/web-guest-rooms.js +135 -0
  158. package/dist/web-guest-rooms.js.map +1 -0
  159. package/dist/web-identity-palette.d.ts +1 -1
  160. package/dist/web-identity-palette.js +2 -2
  161. package/dist/web-identity-palette.js.map +1 -1
  162. package/dist/web-login.d.ts +47 -0
  163. package/dist/web-login.js +115 -0
  164. package/dist/web-login.js.map +1 -1
  165. package/dist/web-mod-agent.d.ts +1 -1
  166. package/dist/web-mod-agent.js +2 -4
  167. package/dist/web-mod-agent.js.map +1 -1
  168. package/dist/web-mod-store.d.ts +5 -0
  169. package/dist/web-mod-store.js +6 -0
  170. package/dist/web-mod-store.js.map +1 -1
  171. package/dist/web-prefs.d.ts +8 -0
  172. package/dist/web-prefs.js +9 -0
  173. package/dist/web-prefs.js.map +1 -1
  174. package/dist/web-preview.d.ts +6 -2
  175. package/dist/web-preview.js +4 -2
  176. package/dist/web-preview.js.map +1 -1
  177. package/dist/web-returning-device.d.ts +39 -0
  178. package/dist/web-returning-device.js +46 -5
  179. package/dist/web-returning-device.js.map +1 -1
  180. package/dist/web-session.d.ts +58 -10
  181. package/dist/web-session.js +104 -22
  182. package/dist/web-session.js.map +1 -1
  183. package/dist/web-settings-tools.d.ts +81 -0
  184. package/dist/web-settings-tools.js +257 -0
  185. package/dist/web-settings-tools.js.map +1 -0
  186. package/dist/web-theme.d.ts +2 -0
  187. package/dist/web-theme.js +10 -1
  188. package/dist/web-theme.js.map +1 -1
  189. package/package.json +1 -1
@@ -15,7 +15,8 @@ import { AgentWidget, checkWidget, fleetBinds, setSidebarWidget, sidebarWidgetRa
15
15
  import { confirmedWrite } from "./settings-confirm.js";
16
16
  import { savePref } from "/assets/app-prefs.js";
17
17
  import { confirmDialog } from "/assets/ui-confirm.js";
18
- import { colorsOff, defaultPlaces, PLACEMENTS, setColorsOff, setDefaultPlaces, useIdentityColors } from "/assets/identity-color.js";
18
+ import { colorsOff, defaultPlaces, identityClasses, identityOf, PLACEMENTS, paintIdentity, setColorsOff, setDefaultPlaces, useIdentityColors } from "/assets/identity-color.js";
19
+ import { CardTitle } from "./settings-learn.js";
19
20
  import "./settings-strings.js";
20
21
 
21
22
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
@@ -42,22 +43,38 @@ function refusal(body) {
42
43
  export function AppearanceSection() {
43
44
  const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
44
45
  if (publicLink) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">${tn("cssPublicLink")}</p></section>`;
45
- return html`<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} /><${SidebarWidgetCard} /><${OwnerCssCard} /><${ModsCard} /><${EffectsCard} /><${DeviceCard} /><${CardLimitsCard} /><${RestoreDefaultsCard} />`;
46
+ const group = (key) => html`<h2 class="s-ap-group">${tn(key)}</h2>`;
47
+ return html`${group("groupLook")}<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} />
48
+ ${group("groupLayout")}<${SidebarWidgetCard} />
49
+ ${group("groupCss")}<${OwnerCssCard} /><${DeviceCard} />
50
+ ${group("groupMods")}<${ModsCard} /><${EffectsCard} />
51
+ ${group("groupAdvanced")}<${CardLimitsCard} /><${RestoreDefaultsCard} />`;
52
+ }
53
+
54
+ /** #2056 B: a sample sidebar row in the host's default colour places, so the choice is seen rather than described. */
55
+ const SAMPLE = { color: { person: "preset:teal" } };
56
+ function ColorSample({ places, off }) {
57
+ const id = identityOf(SAMPLE, { hostDefault: places, off });
58
+ return html`<div class="idc-preview s-colors-sample" aria-hidden="true"><span class=${`inst v-inst ${identityClasses(id)}`} ref=${(el) => paintIdentity(el, id)}>
59
+ <span class="av"><span class="av-text">A</span></span><span class="inst-text"><span class="inst-name">${tn("colorSample")}</span></span></span></div>`;
46
60
  }
47
61
 
48
62
  /**
49
- * #1958: identity colours on this host — where they show for an instance nobody chose for (ring and bar unless
50
- * changed), and a switch that turns them off everywhere while every pick is kept.
63
+ * #1958: identity colours on this host — where they show for an instance nobody chose for (#2056: name, bar and
64
+ * ring unless changed), and a switch that turns them off everywhere while every pick is kept.
51
65
  */
52
66
  function IdentityColorCard() {
53
67
  useIdentityColors();
54
68
  const places = defaultPlaces(), off = colorsOff();
55
69
  const toggle = (p) => setDefaultPlaces(places.includes(p) ? places.filter((x) => x !== p) : [...places, p]);
56
- return html`<section class="card s-appearance s-synced s-colors"><h3>${tn("colorTitle")}</h3>
70
+ return html`<section class="card s-appearance s-synced s-colors"><${CardTitle} title=${tn("colorTitle")} topic="colors" />
57
71
  <p class="sub">${tn("colorLead")}</p>
58
- <fieldset class="idc-places" disabled=${off}><legend>${tn("colorPlaces")}</legend>
59
- ${PLACEMENTS.map((p) => html`<label key=${p} class="check"><input type="checkbox" checked=${places.includes(p)} onChange=${() => toggle(p)} /> ${t(`color.place_${p}`)}</label>`)}
60
- </fieldset>
72
+ <div class="s-colors-row">
73
+ <fieldset class="idc-places" disabled=${off}><legend>${tn("colorPlaces")}</legend>
74
+ ${PLACEMENTS.map((p) => html`<label key=${p} class="check"><input type="checkbox" checked=${places.includes(p)} onChange=${() => toggle(p)} /> ${t(`color.place_${p}`)}</label>`)}
75
+ </fieldset>
76
+ <${ColorSample} places=${places} off=${off} />
77
+ </div>
61
78
  <label class="check"><input type="checkbox" class="s-colors-off" checked=${off} onChange=${(e) => setColorsOff(e.target.checked)} /> <span>${tn("colorOff")}</span></label>
62
79
  <p class="sub">${tn("colorOffLead")}</p>
63
80
  </section>`;
@@ -88,7 +105,7 @@ function RestoreDefaultsCard() {
88
105
  setBusy(false);
89
106
  if (r.ok) toast(tn("restoreDone")); else toast(tn("restoreCssFailed", r.error), false);
90
107
  }
91
- return html`<section class="card s-appearance s-restore"><h3>${tn("restoreTitle")}</h3>
108
+ return html`<section class="card s-appearance s-restore"><${CardTitle} title=${tn("restoreTitle")} topic="restore" />
92
109
  <p class="sub">${tn("restoreLead")}</p>
93
110
  <button type="button" class="btn" disabled=${busy} onClick=${go}>${tn("restoreTitle")}</button>
94
111
  </section>`;
@@ -98,7 +115,7 @@ function RestoreDefaultsCard() {
98
115
  function CardSizeCard() {
99
116
  useStore(cardSizeStore);
100
117
  const size = cardSizeDefault();
101
- return html`<section class="card s-appearance s-synced"><h3>${tn("cardSizeTitle")}</h3>
118
+ return html`<section class="card s-appearance s-synced"><${CardTitle} title=${tn("cardSizeTitle")} topic="cards" />
102
119
  <div class="card-size" role="group" aria-label=${tn("cardSizeTitle")}>
103
120
  ${CARD_SIZES.map((s) => html`<button key=${s} type="button" class=${`seg-item${s === size ? " active" : ""}`} aria-pressed=${s === size ? "true" : "false"}
104
121
  onClick=${() => setCardSizeDefault(s)}>${t(`card.size${s.toUpperCase()}Name`)}</button>`)}
@@ -131,7 +148,7 @@ function CardLimitsCard() {
131
148
  };
132
149
  useEffect(() => { load(); }, []);
133
150
  if (!view) return null;
134
- if (view.error) return html`<section class="card s-appearance"><h3>${tn("cardLimitsTitle")}</h3><p class="sub">${tn("cardLimitsUnavailable")}</p></section>`;
151
+ if (view.error) return html`<section class="card s-appearance"><${CardTitle} title=${tn("cardLimitsTitle")} topic="limits" /><p class="sub">${tn("cardLimitsUnavailable")}</p></section>`;
135
152
  const configured = (g, k) => (view.configured && view.configured[g] && typeof view.configured[g][k] === "number" ? view.configured[g][k] : null);
136
153
  const save = async () => {
137
154
  const body = {};
@@ -148,7 +165,7 @@ function CardLimitsCard() {
148
165
  if (r.ok) { setMsg(tn("cardLimitsSaved")); load(); } else setMsg(tn("cardLimitsFailed", (r.body && r.body.error) || r.status));
149
166
  };
150
167
  const warnings = Array.isArray(view.warnings) ? view.warnings : [];
151
- return html`<section class="card s-appearance"><h3>${tn("cardLimitsTitle")}</h3>
168
+ return html`<section class="card s-appearance"><${CardTitle} title=${tn("cardLimitsTitle")} topic="limits" />
152
169
  <p class="sub">${tn("cardLimitsLead")}</p>
153
170
  <fieldset class="form" disabled=${busy}>
154
171
  ${["portrait", "banner", "instance_quota"].map((g) => html`<div key=${g} class="grid3">
@@ -170,7 +187,7 @@ function CardLimitsCard() {
170
187
  /** #1629 r2 (d): whether this person's screens show agents' themes (and, as they come, effects) — synced. */
171
188
  function ThemesCard() {
172
189
  const s = useStore(themesStore);
173
- return html`<section class="card s-appearance s-synced"><h3>${tn("themesTitle")}</h3>
190
+ return html`<section class="card s-appearance s-synced"><${CardTitle} title=${tn("themesTitle")} topic="themes" />
174
191
  <label class="check"><input type="checkbox" checked=${s.shown} disabled=${safeMode()} onChange=${(e) => setThemesShown(e.target.checked)} />
175
192
  <span>${tn("themesShow")}</span></label>
176
193
  <p class="sub">${safeMode() ? tn("themesSafeMode") : tn("themesShowLead")}</p>
@@ -209,7 +226,7 @@ function SidebarWidgetCard() {
209
226
  toast(tn("widgetSaved"));
210
227
  };
211
228
  const remove = () => { setSidebarWidget(null); setText(""); setError(""); toast(tn("widgetRemoved")); };
212
- return html`<section class="card s-appearance s-synced s-widget"><h3>${tn("widgetTitle")}</h3>
229
+ return html`<section class="card s-appearance s-synced s-widget"><${CardTitle} title=${tn("widgetTitle")} topic="widgets" />
213
230
  <p class="sub">${tn("widgetLead")}</p>
214
231
  <label class="field"><span class="sr-only">${tn("widgetTitle")}</span>
215
232
  <textarea class="s-widget-text" rows="10" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text}
@@ -240,8 +257,8 @@ function OwnerCssCard() {
240
257
  useEffect(() => { load(); }, []);
241
258
  // A trial answered (kept or reverted) changes what is in force: read it again, keeping the editor's text.
242
259
  useEffect(() => { if (state) load({ text: false }); }, [trial && trial.sha]);
243
- if (!state) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">…</p></section>`;
244
- if (state.failed) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="err">${tn("cssLoadFailed")}</p></section>`;
260
+ if (!state) return html`<section class="card s-appearance"><${CardTitle} title=${tn("cssTitle")} topic="css" /><p class="sub">…</p></section>`;
261
+ if (state.failed) return html`<section class="card s-appearance"><${CardTitle} title=${tn("cssTitle")} topic="css" /><p class="err">${tn("cssLoadFailed")}</p></section>`;
245
262
 
246
263
  const save = async () => {
247
264
  setBusy(true); setError("");
@@ -285,7 +302,7 @@ function OwnerCssCard() {
285
302
  ? (state.pending ? tn("cssOnTrial", short(state.active)) : tn("cssInForce", short(state.active)))
286
303
  : tn("cssNone");
287
304
  const assetBytes = state.assets.reduce((n, a) => n + a.bytes, 0);
288
- return html`<section class="card s-appearance s-owner-css"><h3>${tn("cssTitle")}</h3>
305
+ return html`<section class="card s-appearance s-owner-css"><${CardTitle} title=${tn("cssTitle")} topic="css" />
289
306
  <p class="sub">${tn("cssLead")}</p>
290
307
  <p class="s-css-status" role="status">${inForce}</p>
291
308
  <label class="field"><span class="sr-only">${tn("cssTitle")}</span>
@@ -319,7 +336,6 @@ function DeviceCard() {
319
336
  return html`<section class="card s-appearance s-local"><h3>${tn("cssDeviceTitle")}</h3>
320
337
  <label class="check"><input type="checkbox" checked=${off} onChange=${(e) => { setOwnerCssOffHere(e.target.checked); setOff(ownerCssOffHere()); }} />
321
338
  <span>${tn("cssDeviceOff")}</span></label>
322
- <p class="sub">${tn("cssDeviceOffLead")}</p>
323
339
  <p class="sub">${tn("cssSafe")}</p>
324
340
  </section>`;
325
341
  }
@@ -0,0 +1,89 @@
1
+ // #2072 part 3: what a backend or model field in Settings offers — the host's published availability (installed,
2
+ // signed in, new enough: /ui/backends) and the model catalog of that backend for that instance's account (/ui/models).
3
+ // Both answers are the server's cached ones; nothing here makes the server probe. A page asks once and reuses the answer
4
+ // for a short while, so opening several dialogs costs one request each.
5
+ import { useEffect, useState } from "/assets/app-html.js";
6
+
7
+ const TTL_MS = 30_000;
8
+ const cache = new Map(); // url → { at, value, pending }
9
+
10
+ async function getJson(url) {
11
+ const res = await fetch(url, { credentials: "same-origin" });
12
+ if (!res.ok) throw new Error(String(res.status));
13
+ return res.json();
14
+ }
15
+ /** The answer for `url`, shared by every field on the page while it is fresh; null when it cannot be read. */
16
+ function cached(url) {
17
+ const hit = cache.get(url), now = performance.now();
18
+ if (hit && (hit.pending || now - hit.at < TTL_MS)) return hit.pending ?? Promise.resolve(hit.value);
19
+ const pending = getJson(url).then((value) => { cache.set(url, { at: performance.now(), value, pending: null }); return value; })
20
+ .catch(() => { cache.delete(url); return null; });
21
+ cache.set(url, { at: now, value: null, pending });
22
+ return pending;
23
+ }
24
+ /** For tests and after a write that changes the answer. */
25
+ export function forgetCatalogs() { cache.clear(); }
26
+
27
+ function useAnswer(url) {
28
+ const [value, setValue] = useState(undefined); // undefined: loading; null: unavailable
29
+ useEffect(() => {
30
+ if (!url) { setValue(null); return undefined; }
31
+ let live = true;
32
+ setValue(undefined);
33
+ cached(url).then((v) => { if (live) setValue(v); });
34
+ return () => { live = false; };
35
+ }, [url]);
36
+ return value;
37
+ }
38
+
39
+ /** The host's backends with their state ({ name, state, minVersion, … }), or null while loading or unavailable. */
40
+ export function useBackendRows() {
41
+ const answer = useAnswer("/ui/backends");
42
+ return answer && Array.isArray(answer.backends) ? answer.backends : answer === undefined ? undefined : null;
43
+ }
44
+
45
+ /** The model catalog of `backend` for `instance` (its account) — [] when none is cached; undefined while loading. */
46
+ export function useModelCatalog(backend, instance) {
47
+ const url = backend ? `/ui/models?backend=${encodeURIComponent(backend)}${instance ? `&instance=${encodeURIComponent(instance)}` : ""}` : null;
48
+ const answer = useAnswer(url);
49
+ if (answer === undefined) return undefined;
50
+ return answer && Array.isArray(answer.models) ? answer.models : [];
51
+ }
52
+
53
+ /** A state that keeps a backend from being chosen (shown, disabled, with why); "unknown" never does. */
54
+ export const BLOCKING_STATES = ["not_installed", "not_signed_in", "too_old"];
55
+
56
+ /**
57
+ * The backend select's options: every backend the page knows, in its order, each with what the host said about it.
58
+ * The current value is always selectable — a probe never disables what an agent already runs — and one the page does
59
+ * not know (a removed backend) stays first, as before.
60
+ */
61
+ export function backendChoices(known, rows, value, words) {
62
+ const byName = new Map((rows || []).map((r) => [r.name, r]));
63
+ const choice = (name) => {
64
+ const row = byName.get(name), state = row ? row.state ?? (row.installed ? "usable" : row.unknown ? "unknown" : "not_installed") : null;
65
+ const blocked = !!state && BLOCKING_STATES.includes(state) && name !== value;
66
+ const note = state === "not_installed" ? words.notInstalled : state === "not_signed_in" ? words.notSignedIn
67
+ : state === "too_old" ? words.tooOld(row.minVersion ?? "?") : state === "unknown" && name !== value ? words.unchecked : "";
68
+ return { value: name, label: note ? `${name} — ${note}` : name, disabled: blocked };
69
+ };
70
+ const list = known.map(choice);
71
+ return known.includes(value) || !value ? list : [{ value, label: words.removed(value), disabled: false }, ...list];
72
+ }
73
+
74
+ /** The id of the "type another model" choice (never a model name: it has a space). */
75
+ export const OTHER_MODEL = "\u0000other";
76
+
77
+ /**
78
+ * The model select's options: the inherited choice (empty value), the current model first when the catalog does not
79
+ * list it (marked), the catalog, and "Other…" for a name the CLI does not list.
80
+ */
81
+ export function modelChoices(catalog, value, words, inherit) {
82
+ const list = [];
83
+ if (inherit) list.push({ value: "", label: inherit });
84
+ const ids = new Set((catalog || []).map((m) => m.id));
85
+ if (value && !ids.has(value)) list.push({ value, label: `${value} — ${words.notListed}` });
86
+ for (const m of catalog || []) list.push({ value: m.id, label: m.description ? `${m.label || m.id} — ${m.description}` : (m.label || m.id) });
87
+ list.push({ value: OTHER_MODEL, label: words.other });
88
+ return list;
89
+ }
@@ -13,11 +13,13 @@ import { api, confirmedWrite, newKey } from "./settings-confirm.js";
13
13
  import { adminUserIdError, adminUserIdPattern, botHandle, TokenEnvNote, TokenField, verifyBotToken } from "./settings-token.js";
14
14
  import { DiscordServerPicker } from "./settings-discord.js";
15
15
  import { startOperation } from "./settings-apply.js";
16
+ import "./settings-strings.js"; // its words, wherever a picker of this file is used (#2072: Fleet's create dialog too)
17
+ import { backendChoices, modelChoices, OTHER_MODEL, useBackendRows, useModelCatalog } from "./settings-catalog.js";
16
18
  import {
17
19
  ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
18
20
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
19
21
  chLabel, hasOwn, impactOf, nonNegative, nonNegativeInteger, positive, replyGuardSupported, sameValue, visibilityDefault,
20
- webEchoIsGroup, worstImpact,
22
+ classicWebEchoEffective, worstImpact,
21
23
  } from "./settings-model.js";
22
24
 
23
25
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
@@ -32,9 +34,36 @@ export function Select({ value, options, onChange, disabled, id, label }) {
32
34
  return html`<select id=${id} value=${value} disabled=${disabled} aria-label=${label} onChange=${(e) => onChange(e.target.value)}>
33
35
  ${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}</select>`;
34
36
  }
37
+ /**
38
+ * #2072: a backend field shows every backend with what this host says about it — one it cannot run is disabled with
39
+ * why (not installed, not signed in, too old); one not checked yet stays selectable; the current value always can be.
40
+ */
35
41
  export function BackendSelect({ value, onChange, disabled, id }) {
36
- return html`<${Select} id=${id} value=${value} disabled=${disabled} onChange=${onChange}
37
- options=${backendOptions(value).map((o) => (o.unavailable ? { value: o.value, label: tn("backendUnavailable", o.value) } : o))} />`;
42
+ const rows = useBackendRows();
43
+ const known = backendOptions(value).filter((o) => !o.unavailable).map((o) => o.value);
44
+ const opts = backendChoices(known, rows || [], value, {
45
+ notInstalled: tn("backendNotInstalled"), notSignedIn: tn("backendNotSignedIn"), tooOld: (v) => tn("backendTooOld", v),
46
+ unchecked: tn("backendUnchecked"), removed: (v) => tn("backendUnavailable", v),
47
+ });
48
+ return html`<select id=${id} value=${value} disabled=${disabled} onChange=${(e) => onChange(e.target.value)}>
49
+ ${opts.map((o) => html`<option key=${o.value} value=${o.value} disabled=${o.disabled}>${o.label}</option>`)}</select>`;
50
+ }
51
+ /**
52
+ * #2072: a model field is a list — that backend's models for this account (cached; never a probe), the current one
53
+ * first and marked when the CLI does not list it, and "Other…" for a name it does not list (a text box opens with
54
+ * the current value in it: choosing "Other…" alone changes nothing — emptying the box is what inherits, #2098 review).
55
+ * `inherit`: the label of the empty choice (inherit / none), or omit it when the field has none.
56
+ */
57
+ export function ModelSelect({ value, onChange, disabled, id, backend, instance, inherit }) {
58
+ const catalog = useModelCatalog(backend, instance);
59
+ const [typing, setTyping] = useState(false);
60
+ const opts = modelChoices(catalog || [], value, { notListed: tn("modelNotListed"), other: tn("modelOther") }, inherit);
61
+ return html`<div class="model-pick">
62
+ <select id=${id} value=${typing ? OTHER_MODEL : value} disabled=${disabled}
63
+ onChange=${(e) => { const v = e.target.value; if (v === OTHER_MODEL) setTyping(true); else { setTyping(false); onChange(v); } }}>
64
+ ${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}</select>
65
+ ${typing ? html`<input type="text" aria-label=${tn("modelOtherLabel")} placeholder=${tn("modelOtherPlaceholder")} disabled=${disabled}
66
+ value=${value} onInput=${(e) => onChange(e.target.value)} />` : null}</div>`;
38
67
  }
39
68
  export function Impact({ schema, field }) { return html`<span class="impact">${impactText(impactOf(schema, field))}</span>`; }
40
69
  export function Label({ text, schema, field, htmlFor }) {
@@ -228,7 +257,8 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
228
257
  <input id="ag-dn" type="text" placeholder=${name} disabled=${f.inherit.display_name} value=${shown("display_name", f.display_name, defaults.display_name || "")} onInput=${(e) => set("display_name")(e.target.value)} /></${Override}></div>
229
258
  <div class="field">${L(tn("description"), "instance.description", "ag-desc")}<input id="ag-desc" type="text" value=${f.description} onInput=${(e) => set("description")(e.target.value)} /></div>
230
259
  <div class="field">${L(tn("backend"), "instance.backend", "ag-be")}<${BackendSelect} id="ag-be" value=${f.backend} onChange=${set("backend")} /></div>
231
- <div class="field">${L(tn("model"), "instance.model", "ag-model")}<input id="ag-model" type="text" placeholder=${tn("inheritPlaceholder")} value=${f.model} onInput=${(e) => set("model")(e.target.value)} /></div>
260
+ <div class="field">${L(tn("model"), "instance.model", "ag-model")}<${ModelSelect} id="ag-model" value=${f.model} onChange=${set("model")}
261
+ backend=${f.backend || defaults.backend || "claude-code"} instance=${name} inherit=${tn("inheritPlaceholder")} /></div>
232
262
  </div>
233
263
  <div class="field">${L(tn("workingDir"), "instance.working_directory", "ag-wd")}<input id="ag-wd" type="text" value=${f.working_directory} onInput=${(e) => set("working_directory")(e.target.value)} /></div>
234
264
  <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "instance.auto_pause_after", "ag-auto")}
@@ -264,8 +294,8 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
264
294
  onChange=${(e) => set("lightweight")(e.target.checked)} /><span>${tn("lightweight")}</span></label></${Override}></div>
265
295
  <div class="field">${L(tn("modelFailover"), "instance.model_failover", "ag-fo")}
266
296
  <${Override} inherit=${f.inherit.model_failover} onInherit=${setInherit("model_failover")}>
267
- <input id="ag-fo" type="text" placeholder="e.g. sonnet" disabled=${f.inherit.model_failover}
268
- value=${shown("model_failover", f.model_failover, (defaults.model_failover && defaults.model_failover[0]) || "")} onInput=${(e) => set("model_failover")(e.target.value)} /></${Override}>
297
+ <${ModelSelect} id="ag-fo" disabled=${f.inherit.model_failover} backend=${f.backend || defaults.backend || "claude-code"} instance=${name} inherit=${tn("modelNone")}
298
+ value=${shown("model_failover", f.model_failover, (defaults.model_failover && defaults.model_failover[0]) || "")} onChange=${set("model_failover")} /></${Override}>
269
299
  <${Feedback} warning=${failoverWarn} /></div>
270
300
  </div>
271
301
  <div class="field">${L(tn("systemPrompt"), "instance.systemPrompt", "ag-sys")}<input id="ag-sys" type="text" placeholder="e.g. file:prompts/dev.md" value=${f.systemPrompt} onInput=${(e) => set("systemPrompt")(e.target.value)} /></div>
@@ -557,7 +587,7 @@ export function ClassicDialog({ room, ctx, onClose }) {
557
587
  backend: c.backend || inherited.backend, model: c.model || "",
558
588
  auto_pause_after: String(c.auto_pause_after ?? inherited.auto_pause_after), tool_progress: c.tool_progress ?? inherited.tool_progress,
559
589
  reply_completion_guard: c.reply_completion_guard ?? inherited.reply_completion_guard,
560
- collab: !!c.collab, web_echo: !!c.web_echo, context_lines: String(c.context_lines ?? cd.context_lines ?? 5),
590
+ collab: !!c.collab, web_echo: classicWebEchoEffective(c, fleet), context_lines: String(c.context_lines ?? cd.context_lines ?? 5),
561
591
  inherit: { auto_pause_after: !hasOwn(c, "auto_pause_after"), tool_progress: !hasOwn(c, "tool_progress"), reply_completion_guard: !hasOwn(c, "reply_completion_guard") },
562
592
  }));
563
593
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
@@ -580,12 +610,9 @@ export function ClassicDialog({ room, ctx, onClose }) {
580
610
  auto_pause_after: hasOwn(c, "auto_pause_after") ? c.auto_pause_after : null,
581
611
  tool_progress: hasOwn(c, "tool_progress") ? c.tool_progress : null,
582
612
  reply_completion_guard: hasOwn(c, "reply_completion_guard") ? c.reply_completion_guard : null,
583
- collab: !!c.collab, context_lines: c.context_lines ?? cd.context_lines ?? 5, web_echo: !!c.web_echo,
613
+ collab: !!c.collab, context_lines: c.context_lines ?? cd.context_lines ?? 5, web_echo: classicWebEchoEffective(c, fleet),
584
614
  });
585
615
  if (!Object.keys(patch).length) { onClose(); return; }
586
- // #1320: turning web echo on for a group posts every web message where all its members can read it.
587
- if (patch.web_echo === true && !c.web_echo && webEchoIsGroup(c.channelId)
588
- && !(await ask(tn("webEchoGroupConfirm"), { confirmLabel: tn("webEchoEnable") }))) return;
589
616
  if (!lease.current()) return;
590
617
  ctx.stageClassic(c, patch);
591
618
  onClose();
@@ -594,7 +621,8 @@ export function ClassicDialog({ room, ctx, onClose }) {
594
621
  return html`<${EditDialog} title=${c.name || c.instanceName} subtitle=${`ID: ${c.instanceName} · ${tn(`exec_${runtime.execution}`)}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${CLASSIC_FIELDS} schema=${schema}>
595
622
  <div class="grid2">
596
623
  <div class="field">${L(tn("backend"), "classic.backend", "cl-be")}<${BackendSelect} id="cl-be" value=${f.backend} onChange=${set("backend")} /></div>
597
- <div class="field">${L(tn("model"), "classic.model", "cl-model")}<input id="cl-model" type="text" placeholder=${inherited.model || tn("inheritPlaceholder")} value=${f.model} onInput=${(e) => set("model")(e.target.value)} /></div>
624
+ <div class="field">${L(tn("model"), "classic.model", "cl-model")}<${ModelSelect} id="cl-model" value=${f.model} onChange=${set("model")}
625
+ backend=${f.backend} instance=${c.instanceName} inherit=${inherited.model ? tn("inheritValue", inherited.model) : tn("inheritPlaceholder")} /></div>
598
626
  <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "classic.auto_pause_after", "cl-auto")}
599
627
  <${Override} inherit=${f.inherit.auto_pause_after} onInherit=${setInherit("auto_pause_after")}>
600
628
  <input id="cl-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} disabled=${f.inherit.auto_pause_after}
@@ -4,10 +4,11 @@ import { register, t } from "/assets/app-i18n.js";
4
4
  import { appStore, useStore } from "/assets/app-store.js";
5
5
  import { confirmDialog } from "/assets/ui-confirm.js";
6
6
  import { OwnerEffects } from "./settings-owner-effects.js";
7
+ import { CardTitle } from "./settings-learn.js";
7
8
 
8
9
  register("effectSettings", {
9
10
  en: {
10
- title: "Effects", lead: "Manage effect files and save selections for a chat. Saving replaces that chat’s parameters with the values below.", folder: "Upload an effect folder", files: "Choose effect.json and worker.js, with optional raster assets. Up to 1 MiB after processing.",
11
+ title: "Effects", lead: "Your effect files, and which one a chat uses.", folder: "Upload an effect folder", files: "effect.json + worker.js (and pictures), up to 1 MiB.",
11
12
  inUse: "This effect is still in use and cannot be deleted.", quota: "Your library reached its 10-effect or 8 MiB limit. Delete unused copies first.", rateLimited: "Too many changes. Wait a moment, then try again.",
12
13
  loading: "Loading effects…", retry: "Reload library", empty: "No effects in your library.", failed: "Couldn't load the library. Try again.", actionFailed: "Couldn't save this change. Try again.", savedRefresh: "Saved. Reload the library to see the result.",
13
14
  uploadFailed: "Couldn't upload this folder. Check its files and size, then try again.", public: "Effect management isn't available on a temporary public link.", readOnly: "Read-only session: sign in as an admin to change effects.",
@@ -16,7 +17,7 @@ register("effectSettings", {
16
17
  fps: "Host FPS p50 / p10", tasks: "Host long task p95", noTasks: "Not observed", standard: "Standard device", constrained: "Constrained device", params: "Parameters", badParams: "Check the parameter values.", more: "Show more", count: "{0} effects", one: "1 effect", profileFailed: "Couldn't read profile history. Try again.",
17
18
  },
18
19
  "zh-TW": {
19
- title: "效果", lead: "管理效果檔案,並儲存聊天的效果選擇。儲存時會以下方數值取代該聊天的效果參數。", folder: "上傳效果資料夾", files: "選擇 effect.json、worker.js,以及可選的點陣圖素材;處理後最多 1 MiB。",
20
+ title: "效果", lead: "你的效果檔案,以及每個聊天使用哪一個。", folder: "上傳效果資料夾", files: "effect.json + worker.js(可加圖片),最多 1 MiB。",
20
21
  inUse: "這個效果仍在使用中,無法刪除。", quota: "你的程式庫已達 10 個效果或 8 MiB 上限,請先刪除未使用的副本。", rateLimited: "變更太頻繁,請稍候再試。",
21
22
  loading: "正在載入效果…", retry: "重新載入程式庫", empty: "程式庫中還沒有效果。", failed: "無法載入程式庫,請重試。", actionFailed: "無法儲存變更,請重試。", savedRefresh: "已儲存,請重新載入程式庫查看結果。",
22
23
  uploadFailed: "無法上傳這個資料夾,請檢查檔案與大小後重試。", public: "臨時公開連結不提供效果管理。", readOnly: "目前為唯讀登入,請以管理員身分登入才能修改效果。",
@@ -180,7 +181,7 @@ export function EffectsCard() {
180
181
  if (!current(op.seq)) return; finish(op.seq);
181
182
  if (yes) await change(`/ui/effects/${id}`, undefined, "DELETE");
182
183
  };
183
- return html`<section class="card s-appearance s-effects"><h3>${tn("title")}</h3><p class="sub">${tn("lead")}</p>
184
+ return html`<section class="card s-appearance s-effects"><${CardTitle} title=${tn("title")} topic="effects" /><p class="sub">${tn("lead")}</p>
184
185
  <div class="s-fx-actions"><button type="button" class="btn" disabled=${busy} onClick=${reload}>${tn("retry")}</button>
185
186
  ${view?.canWrite ? html`<button class="btn s-fx-choose" type="button" disabled=${busy} onClick=${() => uploadInput.current?.click()}>${tn("folder")}</button><input ref=${uploadInput} class="offscreen s-fx-upload" aria-label=${tn("folder")} tabindex="-1" type="file" webkitdirectory=${true} directory=${true} multiple disabled=${busy} onChange=${upload} />` : null}</div>
186
187
  ${busy ? html`<p class="sub" role="status">${tn("loading")}</p>` : null}${error ? html`<p class="err" role="status">${error}</p>` : null}${message ? html`<p class="sub" role="status">${message}</p>` : null}
@@ -0,0 +1,25 @@
1
+ // #2056 B: a card title with a small "?" that opens the card's section of the docs, so Settings → Web appearance
2
+ // keeps one short line per control and the long explanations live in docs/web-dashboard(.zh-TW).md.
3
+ import { html } from "/assets/app-html.js";
4
+ import { lang, t } from "/assets/app-i18n.js";
5
+ import "./settings-strings.js";
6
+
7
+ /**
8
+ * #2056 B: where each card's long explanation lives — a heading of docs/web-dashboard(.zh-TW).md on GitHub, so the
9
+ * page keeps one short line per control and a small "?" for the rest.
10
+ */
11
+ const DOCS = { en: "https://github.com/songsid/AgEnD/blob/main/docs/web-dashboard.md", "zh-TW": "https://github.com/songsid/AgEnD/blob/main/docs/web-dashboard.zh-TW.md" };
12
+ export const LEARN_ANCHORS = {
13
+ colors: ["identity-colours", "識別色"], css: ["custom-css-settings--web-appearance", "自訂-css設定--網頁外觀"], themes: ["agents-themes", "agent-的主題"],
14
+ cards: ["identity-cards", "身分卡"], limits: ["card-upload-limits", "身分卡上傳限制"], widgets: ["widgets", "小工具"], mods: ["mods", "mod"],
15
+ effects: ["effects", "效果"], restore: ["restore-defaults", "還原預設值"],
16
+ };
17
+ export function learnHref(topic, language = lang()) {
18
+ const zh = language === "zh-TW";
19
+ return `${DOCS[zh ? "zh-TW" : "en"]}#${encodeURIComponent(LEARN_ANCHORS[topic][zh ? 1 : 0])}`;
20
+ }
21
+ /** A card title with its "?" link to the docs (a new tab; the page itself never fetches anything). */
22
+ export function CardTitle({ title, topic }) {
23
+ return html`<h3 class="s-ap-title"><span>${title}</span>${topic ? html`<a class="s-learn" href=${learnHref(topic)} target="_blank" rel="noopener noreferrer"
24
+ aria-label=${t("settings.learnMore", title)} title=${t("settings.learnMore", title)}>?</a>` : null}</h3>`;
25
+ }
@@ -222,8 +222,12 @@ export function accessConfirmations(previousAccess, nextMode, nextUsers) {
222
222
  return out;
223
223
  }
224
224
 
225
- /** #1320: only a short positive numeric id is a Telegram private chat; Discord snowflakes and negative ids are groups. */
226
- export const webEchoIsGroup = (channelIdValue) => !/^\d{1,12}$/.test(String(channelIdValue || ""));
225
+ /**
226
+ * #2054: whether a ClassicBot room copies web chat, as the fleet decides it (ClassicChannelManager's load +
227
+ * classicWebEchoOn): an absent `web_echo` follows the fleet-wide `web.echo_to_channel` (default on); any other value
228
+ * echoes only when it is `true` (a `null`, `0` or string reads as off, as the manager reads it).
229
+ */
230
+ export const classicWebEchoEffective = (room, fleet) => (room?.web_echo === undefined ? fleet?.web?.echo_to_channel !== false : room.web_echo === true);
227
231
 
228
232
  // ── Developer YAML: the config subset's writer and reader, exactly as settings.html had them (round-trip verified) ──
229
233
  export function toYaml(value, indent = 0) {
@@ -12,6 +12,7 @@ import { toast } from "/assets/ui-toast.js";
12
12
  import { confirmDialog } from "/assets/ui-confirm.js";
13
13
  import { startOwnerCssTrial } from "/assets/owner-css.js";
14
14
  import { AgentWidget, checkWidget, fleetBinds, instanceBinds } from "/assets/agent-widget.js";
15
+ import { CardTitle } from "./settings-learn.js";
15
16
  import "./settings-strings.js";
16
17
 
17
18
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
@@ -204,8 +205,8 @@ export function ModsCard() {
204
205
  setState(b && Array.isArray(b.mods) ? { mods: b.mods.filter((m) => m && typeof m.id === "string"), staged, proposals } : { failed: true });
205
206
  };
206
207
  useEffect(() => { load(); }, []);
207
- if (!state) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="sub">…</p></section>`;
208
- if (state.failed) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="err">${tn("modsLoadFailed")}</p></section>`;
208
+ if (!state) return html`<section class="card s-appearance s-mods"><${CardTitle} title=${tn("modsTitle")} topic="mods" /><p class="sub">…</p></section>`;
209
+ if (state.failed) return html`<section class="card s-appearance s-mods"><${CardTitle} title=${tn("modsTitle")} topic="mods" /><p class="err">${tn("modsLoadFailed")}</p></section>`;
209
210
  const stage = async () => {
210
211
  const f = file.current && file.current.files && file.current.files[0];
211
212
  if (!f) return;
@@ -238,7 +239,7 @@ export function ModsCard() {
238
239
  if (!r || !r.ok) { toast(tn("modFailed", r ? r.status : "network"), false); return; }
239
240
  download(await r.text(), `${m.name}-${m.version}.agendmod.json`);
240
241
  };
241
- return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3>
242
+ return html`<section class="card s-appearance s-mods"><${CardTitle} title=${tn("modsTitle")} topic="mods" />
242
243
  <p class="sub">${tn("modsLead")}</p>
243
244
  ${state.staged ? html`<${Plan} key=${state.staged.stage} staged=${state.staged} onDone=${load} />` : html`<div class="s-mod-import">
244
245
  <label class="btn"><${Icon} name="attach" size=${16} /><span>${tn("modChoose")}</span>
@@ -7,24 +7,24 @@ import { getAppEffects } from "./app-effects.js";
7
7
  import { ownerEffectsStore, watchOwnerEffects, refreshOwnerEffects, saveOwnerEffects } from "/assets/owner-effect-prefs.js";
8
8
  register("ownerEffects", {
9
9
  en: {
10
- title: "Your effect choices", operatorTitle: "Host-wide effect choices", person: "These choices follow this platform sign-in. Discord and Telegram accounts stay separate.",
11
- operator: "Host-wide choices: everyone signed in as the host operator shares these slots.",
10
+ title: "Your effect choices", operatorTitle: "Host-wide effect choices", person: "Follows your sign-in; Discord and Telegram are separate.",
11
+ operator: "Shared by everyone signed in as the host.",
12
12
  sidebar: "Sidebar", header: "Header", page: "Page", box: "Box", cursor: "Cursor", none: "None", savedChoice: "Saved choice",
13
13
  unknown: "Choices are unavailable. Reload to try again.", changed: "This page can no longer access these choices. Reload and sign in again.",
14
14
  unavailable: "No longer available", clear: "Clear", reload: "Reload choices", saved: "Saved.", failed: "Couldn't save. Reload choices and try again.",
15
15
  savedRefresh: "Saved, but the choices couldn't be refreshed. Reload to check.", readOnly: "Sign in as an admin to change these choices.",
16
- playback: "Playback requires Effects and preview consent. The cursor follows the same consent and falls back to stock cursors when unavailable.",
17
- pause: "Pause", resume: "Resume", running: "Running", cursorFailed: "The cursor could not be applied here. Clear or reload the choice to try again.", starting: "Starting", queued: "Waiting for a slot", paused: "Paused", off: "Playback is off or unavailable here.", budget: "Paused to protect page responsiveness. Resume keeps the three-pauses safety limit.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
16
+ playback: "Plays once Effects and previews are allowed.",
17
+ pause: "Pause", resume: "Resume", running: "Running", cursorFailed: "The cursor could not be applied here. Clear or reload the choice to try again.", starting: "Starting", queued: "Waiting for a slot", paused: "Paused", off: "Playback is off or unavailable here.", budget: "Paused to keep the page responsive.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
18
18
  },
19
19
  "zh-TW": {
20
- title: "你的效果選擇", operatorTitle: "主機共用效果選擇", person: "選擇會跟隨這個平台的登入身分;Discord 與 Telegram 帳號各自獨立。",
21
- operator: "主機共用選擇:所有以主機操作者身分登入的人,都會共用這些欄位。",
20
+ title: "你的效果選擇", operatorTitle: "主機共用效果選擇", person: "跟隨你的登入身分;Discord 與 Telegram 各自獨立。",
21
+ operator: "所有以主機身分登入的人共用。",
22
22
  sidebar: "側邊欄", header: "頁首", page: "頁面", box: "區塊", cursor: "游標", none: "無", savedChoice: "已儲存的選擇",
23
23
  unknown: "目前無法取得選擇,請重新載入再試。", changed: "這個頁面已無法存取選擇,請重新載入並登入。",
24
24
  unavailable: "已無法使用", clear: "清除", reload: "重新載入選擇", saved: "已儲存。", failed: "無法儲存,請重新載入選擇再試。",
25
25
  savedRefresh: "已儲存,但無法更新畫面;請重新載入確認。", readOnly: "請以管理員身分登入才能修改選擇。",
26
- playback: "開啟效果並允許預覽後才會播放;游標也遵循相同的同意設定;無法使用時會還原一般游標。",
27
- pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
26
+ playback: "開啟效果並允許預覽後才會播放。",
27
+ pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
28
28
  },
29
29
  });
30
30
  const text = key => t(`ownerEffects.${key}`);