@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.13

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 (82) hide show
  1. package/README.md +6 -0
  2. package/README.zh-TW.md +6 -0
  3. package/dist/config.d.ts +12 -0
  4. package/dist/config.js +13 -4
  5. package/dist/config.js.map +1 -1
  6. package/dist/fleet-manager.d.ts +36 -1
  7. package/dist/fleet-manager.js +128 -30
  8. package/dist/fleet-manager.js.map +1 -1
  9. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  10. package/dist/general-knowledge/skills/web-appearance/SKILL.md +20 -3
  11. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
  12. package/dist/outbound-schemas.d.ts +14 -1
  13. package/dist/outbound-schemas.js +8 -2
  14. package/dist/outbound-schemas.js.map +1 -1
  15. package/dist/public-web-gateway.js +4 -0
  16. package/dist/public-web-gateway.js.map +1 -1
  17. package/dist/restart-progress.d.ts +16 -1
  18. package/dist/restart-progress.js +74 -15
  19. package/dist/restart-progress.js.map +1 -1
  20. package/dist/settings-api.js +4 -1
  21. package/dist/settings-api.js.map +1 -1
  22. package/dist/settings-confirmation.d.ts +3 -1
  23. package/dist/settings-confirmation.js +2 -0
  24. package/dist/settings-confirmation.js.map +1 -1
  25. package/dist/settings-effect.js +2 -1
  26. package/dist/settings-effect.js.map +1 -1
  27. package/dist/settings-http-confirmation.d.ts +11 -0
  28. package/dist/settings-http-confirmation.js +10 -0
  29. package/dist/settings-http-confirmation.js.map +1 -1
  30. package/dist/ui/chat-effects.js +10 -3
  31. package/dist/ui/chat-strings.js +2 -0
  32. package/dist/ui/effects-waker.js +70 -0
  33. package/dist/ui/fleet-canvas-layout.js +10 -4
  34. package/dist/ui/fleet-canvas.js +32 -2
  35. package/dist/ui/fleet-route-scene.js +207 -0
  36. package/dist/ui/fleet-route-worker.js +21 -0
  37. package/dist/ui/fleet-route.js +88 -0
  38. package/dist/ui/fleet-traffic.js +27 -11
  39. package/dist/ui/fleet-usage.js +35 -1
  40. package/dist/ui/panel-chat.js +43 -23
  41. package/dist/ui/panel-fleet.js +3 -3
  42. package/dist/ui/settings-appearance.js +2 -1
  43. package/dist/ui/settings-apply.js +3 -3
  44. package/dist/ui/settings-confirm.js +7 -2
  45. package/dist/ui/settings-owner-effects.js +6 -4
  46. package/dist/ui/shared/app-i18n.js +4 -0
  47. package/dist/ui/shared/app-prefs.js +12 -0
  48. package/dist/ui/shared/app-shell.js +38 -2
  49. package/dist/ui/shared/app-themes.js +116 -1
  50. package/dist/ui/shared/app.css +47 -3
  51. package/dist/ui/shared/card-strings.js +4 -0
  52. package/dist/ui/shared/identity-card.js +24 -6
  53. package/dist/ui/shared/identity-color.js +11 -7
  54. package/dist/ui/shared/ui-icons.js +2 -0
  55. package/dist/ui/shared/ui-menu.js +9 -3
  56. package/dist/ui/shared/web-authorize.js +47 -0
  57. package/dist/web-api.js +18 -1
  58. package/dist/web-api.js.map +1 -1
  59. package/dist/web-appearance.js +3 -1
  60. package/dist/web-appearance.js.map +1 -1
  61. package/dist/web-effect.d.ts +1 -1
  62. package/dist/web-host-guard.d.ts +11 -0
  63. package/dist/web-host-guard.js +11 -0
  64. package/dist/web-host-guard.js.map +1 -1
  65. package/dist/web-libavoid.d.ts +14 -0
  66. package/dist/web-libavoid.js +46 -0
  67. package/dist/web-libavoid.js.map +1 -0
  68. package/dist/web-owner-css-store.d.ts +3 -2
  69. package/dist/web-owner-css-store.js +9 -4
  70. package/dist/web-owner-css-store.js.map +1 -1
  71. package/dist/web-prefs.js +13 -0
  72. package/dist/web-prefs.js.map +1 -1
  73. package/dist/web-settings-tools.d.ts +5 -0
  74. package/dist/web-settings-tools.js +34 -6
  75. package/dist/web-settings-tools.js.map +1 -1
  76. package/dist/web-theme.d.ts +29 -0
  77. package/dist/web-theme.js +89 -1
  78. package/dist/web-theme.js.map +1 -1
  79. package/dist/yaml-shared-nodes.d.ts +25 -0
  80. package/dist/yaml-shared-nodes.js +185 -0
  81. package/dist/yaml-shared-nodes.js.map +1 -0
  82. package/package.json +2 -1
@@ -179,7 +179,9 @@ function endOf(L, name) {
179
179
  // ── Routing: lanes the layout keeps empty ──
180
180
 
181
181
  /** A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards. */
182
- const laneOffset = (o) => Math.max(-OC.PAD / 2 + 6, Math.min(OC.PAD / 2 - 6, o * 0.5));
182
+ const laneOffset = (o, PAD = OC.PAD) => Math.max(-PAD / 2 + 6, Math.min(PAD / 2 - 6, o * 0.5));
183
+ /** The layout's own box padding and gutter (a phone's are narrower). */
184
+ const spacing = (L) => ({ PAD: L.pad ?? OC.PAD, GAP: L.gap ?? OC.GAP });
183
185
 
184
186
  const firstBusOf = (L) => Math.min(...L.boxes.map((b) => b.busY));
185
187
  /** From an end up to its row's bus lane: the points, and the lane's y. `o` spreads lines sharing a lane. */
@@ -194,9 +196,9 @@ function outward(L, end, o) {
194
196
  const x = b.x + b.w / 2 + o * 1.5;
195
197
  return { pts: [{ x, y: b.y }, { x, y: b.busY + o }], bus: b.busY + o };
196
198
  }
197
- const { PAD, GAP, NW, NH } = OC;
199
+ const { NW, NH } = OC, { PAD, GAP } = spacing(L);
198
200
  const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
199
- const io = laneOffset(o);
201
+ const io = laneOffset(o, PAD);
200
202
  const leftLane = b.x + PAD / 2 + io, rightLane = b.x + b.w - PAD / 2 + io;
201
203
  let pts;
202
204
  if (col === 0 || col === b.cols - 1) {
@@ -217,9 +219,9 @@ function outward(L, end, o) {
217
219
  * else down into the gutter under its row (no card is there) and across. The last point is on the lane.
218
220
  */
219
221
  function toLane(L, end, lane, o) {
220
- const { PAD, GAP, NW, NH } = OC;
222
+ const { NW, NH } = OC, { PAD, GAP } = spacing(L);
221
223
  const b = end.b, card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
222
- const io = laneOffset(o);
224
+ const io = laneOffset(o, PAD);
223
225
  const left = lane < b.x + b.w / 2;
224
226
  if ((left && col === 0) || (!left && col === b.cols - 1)) {
225
227
  const cy = card.y + NH / 2 + io;
@@ -230,8 +232,8 @@ function toLane(L, end, lane, o) {
230
232
  }
231
233
  /** Two cards in one open box: joined inside it, through the side lane nearer to them both (never out to the bus). */
232
234
  function inBox(L, a, z, o) {
233
- const b = a.b, { PAD } = OC;
234
- const io = laneOffset(o);
235
+ const b = a.b, { PAD } = spacing(L);
236
+ const io = laneOffset(o, PAD);
235
237
  const mid = ((a.i % b.cols) + (z.i % b.cols)) / 2;
236
238
  const lane = mid <= (b.cols - 1) / 2 ? b.x + PAD / 2 + io : b.x + b.w - PAD / 2 + io;
237
239
  return [...toLane(L, a, lane, o), ...toLane(L, z, lane, o).reverse()];
@@ -289,9 +291,12 @@ const settled = (phase) => (phase === "fading" || phase === "stale" ? "fading" :
289
291
 
290
292
  /**
291
293
  * The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
292
- * { key, hue, phase, n, from, to, d, pts, badge } — `from`/`to` the instance (or team) names it joins, for search.
294
+ * { key, hue, phase, n, from, to, d, pts, badge, a, z, routed } — `from`/`to` the instance (or team) names it joins, for
295
+ * search; `a`/`z` its two ends (fleet-route-scene.js routes the ones on cards). `routed` (#2182): the router's points by
296
+ * line key (fleet-route.js); a line it has none for keeps its lane route — or, while a new answer is on its way, the
297
+ * points the same two ends and kind had in the last one (a line changing phase keeps its place).
293
298
  */
294
- export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
299
+ export function trafficEdges(items, L, { far = false, max = MAX_EDGES, routed = null } = {}) {
295
300
  if (!L.boxes.length && L.tops.length < 2) return [];
296
301
  // A top's way out is the first row's bus: with no box there is none, so only top ↔ top lines (betweenTops) are drawn.
297
302
  const groups = new Map();
@@ -321,10 +326,21 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
321
326
  add(`${a.key}>${z.key}|${hue(it.kind)}|${phase}`, { a, z, hue: hue(it.kind), phase, n: 1, names });
322
327
  }
323
328
  const list = [...groups.entries()].sort((x, y) => RANK[x[1].phase] - RANK[y[1].phase]).slice(0, max);
329
+ const lent = new Set();
330
+ const routedBy = (key) => {
331
+ if (!routed || far) return null;
332
+ const exact = routed.get(key);
333
+ if (exact) return exact;
334
+ const kin = key.slice(0, key.lastIndexOf("|") + 1);
335
+ for (const [k, pts] of routed) if (k.startsWith(kin) && !groups.has(k) && !lent.has(k)) { lent.add(k); return pts; }
336
+ return null;
337
+ };
324
338
  return list.map(([key, g], i) => {
325
339
  const o = ((i % 7) - 3) * 7; // neighbours 7 px apart (within the ±22 px bus lane)
326
- const pts = route(L, g.a, g.z, o);
327
- return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d: pathOf(pts), pts, badge: g.n > 1 ? badgeAt(pts) : null };
340
+ const viaRouter = routedBy(key);
341
+ const pts = viaRouter ?? route(L, g.a, g.z, o);
342
+ return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d: pathOf(pts), pts, badge: g.n > 1 ? badgeAt(pts) : null,
343
+ a: g.a, z: g.z, routed: !!viaRouter };
328
344
  });
329
345
  }
330
346
 
@@ -183,6 +183,38 @@ export function zonesChanged(report) {
183
183
  return zones.length > 1 || (zones.length === 1 && zones[0] !== report.zone) ? zones : null;
184
184
  }
185
185
 
186
+ /** Estimated pixel width of one character in the 9 px `.us-axis` label. */
187
+ const LABEL_CHAR_W = 5.5;
188
+
189
+ /**
190
+ * The 0-based indices of dates that should receive an x-axis label.
191
+ * Keeps the first (0) and last (n-1) when they are far enough apart not to
192
+ * overlap. When first and last are too close (n-1 < step), only the last
193
+ * (most recent) is shown — overlap-free takes precedence over keeping both
194
+ * ends. Intermediate labels are thinned so that no two consecutive shown
195
+ * labels are closer than `labelWidthPx` pixels apart.
196
+ *
197
+ * @param {number} n Number of data points (dates).
198
+ * @param {number} slotPx Pixels per slot (bar + gap).
199
+ * @param {number} labelWidthPx Estimated rendered width of one label.
200
+ * @returns {Set<number>}
201
+ */
202
+ export function thinXLabels(n, slotPx, labelWidthPx) {
203
+ if (n <= 0) return new Set();
204
+ if (n === 1) return new Set([0]);
205
+ // Minimum slot steps between label centres (+ 2 px breathing room).
206
+ const step = Math.max(1, Math.ceil((labelWidthPx + 2) / slotPx));
207
+ // If first and last are too close to both be shown without overlap, keep
208
+ // only the last (most recent). The caller's "always first+last" contract
209
+ // is relaxed here: overlap-free takes precedence.
210
+ if (n - 1 < step) return new Set([n - 1]);
211
+ const idx = [0];
212
+ // Add intermediates that are at least `step` slots away from n-1.
213
+ for (let i = step; i < n - step; i += step) idx.push(i);
214
+ idx.push(n - 1); // always include last
215
+ return new Set(idx);
216
+ }
217
+
186
218
  /** The trend's series: the top `TOP` agents by the measure over the range, then "others"; one value per date. */
187
219
  export function trendSeries(report, dates) {
188
220
  const priced = report.totals && report.totals.amount !== null && report.totals.amount > 0;
@@ -230,6 +262,8 @@ function Trend({ report, dates }) {
230
262
  const max = Math.max(...sums, 0) || 1;
231
263
  const what = s.priced ? tu("amount") : tu("trendTokens"), others = s.names.length > TOP;
232
264
  const label = others ? tu("trendLabel", what, TOP, dates[0], dates[dates.length - 1]) : tu("trendLabelAll", what, s.names.length, dates[0], dates[dates.length - 1]);
265
+ // Thin x-axis labels: "MM-DD" (5 chars) × LABEL_CHAR_W ≈ 27.5 px at 9 px sans-serif.
266
+ const shownLabels = thinXLabels(dates.length, slot, 5 * LABEL_CHAR_W);
233
267
  return html`<section class="us-trend card" aria-labelledby="us-trend-h">
234
268
  <h3 id="us-trend-h">${tu("trend")} <span class="note">· ${s.priced ? tu("amount") : tu("trendTokens")}</span></h3>
235
269
  <p class="note us-scale">${tu("scale", fmtMeasure(s.priced, max))}</p>
@@ -243,7 +277,7 @@ function Trend({ report, dates }) {
243
277
  const h = v[i] > 0 ? Math.max(1, ((base - top) * v[i]) / max) : 0;
244
278
  y -= h;
245
279
  return h ? html`<rect key=${k} class=${`us-s${k}`} x=${left + i * slot + 2} y=${y} width=${slot - 4} height=${h}><title>${`${date} · ${s.names[k]}: ${fmtMeasure(s.priced, v[i])}`}</title></rect>` : null;
246
- })}${(dates.length - 1 - i) % 7 === 0 || dates.length <= 8 ? html`<text class="us-axis" x=${left + i * slot + slot / 2} y=${H - 6} text-anchor="middle">${date.slice(5)}</text>` : null}</g>`;
280
+ })}${shownLabels.has(i) ? html`<text class="us-axis us-xlabel" x=${left + i * slot + slot / 2} y=${H - 6} text-anchor="middle">${date.slice(5)}</text>` : null}</g>`;
247
281
  })}
248
282
  </svg></div>
249
283
  <${EarlierHint} wide=${wide} />
@@ -8,8 +8,8 @@
8
8
  // that apply to its state are offered (#1408 rough edges 4 and 7).
9
9
  import { html, useCallback, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
10
10
  import { lang, t } from "/assets/app-i18n.js";
11
- import { appStore, guestPage, useStore } from "/assets/app-store.js";
12
- import { AuthorizeChip } from "/assets/web-authorize.js";
11
+ import { appStore, createStore, guestPage, useStore } from "/assets/app-store.js";
12
+ import { ComposerAuth } from "/assets/web-authorize.js";
13
13
  import { useLease } from "/assets/app-ctx.js";
14
14
  import { modelText, namesOf, namesStore, navStore as instanceNavStore } from "/assets/instance-nav.js";
15
15
  import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
@@ -29,23 +29,34 @@ import { colorMenuItem } from "/assets/identity-color.js";
29
29
  import { IdentityCard, useCard } from "/assets/identity-card.js";
30
30
  import { AgentWidget, instanceBinds, widgetFor } from "/assets/agent-widget.js";
31
31
 
32
- // #1629 r4 §1: the identity card folded into the chat's header — folded by default; open or not is this device's choice.
32
+ // #1629 r4 §1: the identity card under the chat's header — hidden by default; shown or not is this device's choice.
33
+ // #2217 (the user, 2026-10-19): no row of its own. Shown, its banner sits right under the header with Hide card on it, and its
34
+ // picture and bio can be folded away (the banner stays) — this device's choice too, kept the same way. Hidden, a card
35
+ // button in the header brings it back (a guest's header too: it has no ⋯ menu).
33
36
  const CHAT_CARD_KEY = "agend_chat_card";
34
- const chatCardOpen = () => { try { return localStorage.getItem(CHAT_CARD_KEY) === "open"; } catch { return false; } };
37
+ const CHAT_CARD_ROW_KEY = "agend_chat_card_row";
38
+ const readKey = (k, v) => { try { return localStorage.getItem(k) === v; } catch { return false; } };
39
+ const writeKey = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch { /* this page only */ } };
40
+ /** Shared by the header's Show card and the card itself, so the two never disagree. */
41
+ export const chatCardPrefs = createStore({ open: readKey(CHAT_CARD_KEY, "open"), rowHidden: readKey(CHAT_CARD_ROW_KEY, "hidden") });
42
+ export const setChatCardOpen = (open) => { writeKey(CHAT_CARD_KEY, open ? "open" : null); chatCardPrefs.set((s) => ({ ...s, open })); };
43
+ export const setChatCardRowHidden = (rowHidden) => { writeKey(CHAT_CARD_ROW_KEY, rowHidden ? "hidden" : null); chatCardPrefs.set((s) => ({ ...s, rowHidden })); };
35
44
  function ChatCard({ name, card }) {
36
- const [open, setOpen] = useState(chatCardOpen);
37
- if (!card) return null;
38
- const toggle = () => { const next = !open; setOpen(next); try { if (next) localStorage.setItem(CHAT_CARD_KEY, "open"); else localStorage.removeItem(CHAT_CARD_KEY); } catch { /* this page only */ } };
39
- return html`<div data-trust="card" class=${`chat-card${open ? " open" : ""}`}>
40
- <button type="button" class="chat-card-toggle" aria-expanded=${open ? "true" : "false"} onClick=${toggle}>
41
- <${Icon} name="chevron" size=${14} cls=${open ? "caret" : "caret flipped"} /><span>${t(open ? "card.hide" : "card.show")}</span>
42
- ${!open && card.tagline ? html`<span class="chat-card-peek">${card.tagline.text}</span>` : null}
43
- </button>
44
- ${open ? html`<${IdentityCard} name=${name} card=${card} compact=${true} />` : null}
45
+ const prefs = useStore(chatCardPrefs);
46
+ if (!card || !prefs.open) return null;
47
+ return html`<div data-trust="card" class="chat-card open">
48
+ <${IdentityCard} name=${name} card=${card} compact=${true} onHide=${() => setChatCardOpen(false)}
49
+ rowHidden=${prefs.rowHidden} onToggleRow=${() => setChatCardRowHidden(!prefs.rowHidden)} />
45
50
  </div>`;
46
51
  }
52
+ /** The header's way back to a hidden card. */
53
+ function ShowCardButton({ card }) {
54
+ const prefs = useStore(chatCardPrefs);
55
+ if (!card || prefs.open) return null;
56
+ return html`<button type="button" class="icon-btn chat-card-show" aria-label=${t("card.show")} title=${t("card.show")} onClick=${() => setChatCardOpen(true)}><${Icon} name="idcard" /></button>`;
57
+ }
47
58
  import "./chat-strings.js";
48
- import { confirmedWrite } from "./settings-confirm.js";
59
+ import { confirmedWrite, selfConfirmed } from "./settings-confirm.js";
49
60
  import { createChatStore } from "./chat-store.js";
50
61
  import { createThread, msgKey } from "./chat-thread.js";
51
62
  import { createEmojiIndex } from "./chat-emojis.js";
@@ -59,7 +70,7 @@ import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
59
70
  import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
60
71
  import { setFollowsReducedMotion } from "/assets/fx-motion.js";
61
72
  import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
62
- import { startAppEffects, getAppEffects } from "./app-effects.js";
73
+ import { startEffectsWaker, getAppEffects, wakeEffects, effectsLoaded, onEffectsAwake, chatHasEffects } from "./effects-waker.js";
63
74
 
64
75
  const R = () => globalThis.AgendChatRender;
65
76
  const P = () => globalThis.AgendPreview;
@@ -119,8 +130,9 @@ export function boot({ stream, boot: bootData, deps = {} }) {
119
130
  }
120
131
  // #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
121
132
  installTour();
122
- // #2118: viewing current in-scope Chat effects needs no management or personal-slot authority.
123
- startAppEffects({ store });
133
+ // #2118: viewing current in-scope Chat effects needs no management or personal-slot authority. #2193: the runtime
134
+ // itself loads only when there is something to play (effects-waker.js).
135
+ startEffectsWaker({ store });
124
136
  return store;
125
137
  }
126
138
 
@@ -276,6 +288,8 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
276
288
  else if (r && r.held) toast(r.message);
277
289
  else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
278
290
  };
291
+ // #2193: this chat's Canvas Worker placements (its status row's effect_chat_v): none, no effects code runs for it.
292
+ const fxHere = chatHasEffects(name);
279
293
  const items = [
280
294
  // #1523 N2: Details is a page now (the Fleet side of this instance), not a dialog.
281
295
  { key: "details", label: t("chat.details"), icon: "info", onSelect: () => navigate(detailsPath(name)) },
@@ -301,7 +315,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
301
315
  ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", modelText(inst))} aria-label=${t("chat.chipModel", modelText(inst))} onClick=${() => pick("model")}>${modelText(inst)}</button>` : null}
302
316
  ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
303
317
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
304
- <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onPlayback=${publishCanvasPlayback} onClose=${() => setDialog(null)} />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
318
+ <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onPlayback=${publishCanvasPlayback} onClose=${() => setDialog(null)} active=${fxHere} />`}>${fxHere ? html`<${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} />` : null}<${ShowCardButton} card=${card} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
305
319
  <${ChatCard} key=${name} name=${name} card=${card} />
306
320
  <${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
307
321
  <div class="chat-split" ref=${split}>
@@ -385,7 +399,8 @@ function threadEntry(name, doc) {
385
399
  const thread = createThread(list, doc.createElement("div"), {
386
400
  t, tf: t, isUser: (x) => store.isUser(x, name), onJump: (j) => e.sink?.setJump(j), onEmpty: (v) => e.sink?.setEmpty(v),
387
401
  setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
388
- effectTrialStatus: x => getAppEffects()?.trialStatus(x) ?? "localAdmin",
402
+ // #2193: an offer shown while the effects runtime sleeps wakes it; until it answers, the offer is being checked.
403
+ effectTrialStatus: x => { const fx = getAppEffects(); if (fx) return fx.trialStatus(x); if (!effectsLoaded()) { void wakeEffects(); return "checking"; } return "localAdmin"; },
389
404
  effectTrialFailure: x => getAppEffects()?.trialFailure(x),
390
405
  fixEffectTrial: (x, reason) => {
391
406
  if (reason === "reducedMotion") setFollowsReducedMotion(false);
@@ -423,7 +438,12 @@ function follow(name, thread) {
423
438
  else if (kind === "sent") thread.jumpLatest();
424
439
  }));
425
440
  offs.push(P() ? P().onChange(() => thread.refreshCards()) : () => {});
426
- offs.push(getAppEffects()?.subscribeTrial(() => thread.render(store.state.msgs[name] || [])) ?? (() => {}));
441
+ // #2193: the runtime may start later (an offer shown wakes it): its trial updates are followed from then.
442
+ let trialOff = null;
443
+ const watchTrial = () => { trialOff = getAppEffects()?.subscribeTrial(() => thread.render(store.state.msgs[name] || [])) ?? null; };
444
+ watchTrial();
445
+ if (!trialOff) offs.push(onEffectsAwake(() => { watchTrial(); thread.render(store.state.msgs[name] || []); }));
446
+ offs.push(() => trialOff?.());
427
447
  offs.push(panelStore.subscribe(() => thread.refreshCards())); // a card shows whether the panel has its block
428
448
  return () => { for (const off of offs) off(); };
429
449
  }
@@ -493,8 +513,8 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
493
513
  ${s.sendFailures[name] ? html`<div class="failed-send send-failure" role="alert"><span>${s.sendFailures[name].error}</span>
494
514
  <button type="button" class="btn btn-sm" disabled=${!store.canRetrySend(name)} onClick=${() => store.retrySend(name)}>${t("chat.retrySend")}</button></div>` : null}
495
515
  <${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
496
- <p class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</p>
497
- ${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : html`<${AuthorizeChip} />`}
516
+ <div class="composer-row">${guestPage() ? html`<span class="guest-note" title=${t("chat.guestNote")}>${t("chat.guestNote")}</span>` : html`<${ComposerAuth} />`}<span
517
+ class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</span></div>
498
518
  </div></div>`;
499
519
  }
500
520
 
@@ -679,7 +699,7 @@ function DeleteDialog({ name, onClose }) {
679
699
  // navigates anything (the person may be anywhere by then).
680
700
  try {
681
701
  const res = await confirmedWrite(`/ui/instances/${encodeURIComponent(name)}/delete`, { method: "POST", body: { confirm: typed }, label: t("chat.deleteTitle", name),
682
- onPending: () => { handedOver = true; toast(t("app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
702
+ onPending: (_id, view) => { handedOver = true; toast(t(selfConfirmed(view) ? "app.pendingSelfSent" : "app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
683
703
  r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
684
704
  } catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
685
705
  if (r && r.error) { toast(r.error, false); if (!handedOver && lease.current()) setBusy(false); return; }
@@ -17,7 +17,7 @@ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
17
17
  import { Icon } from "/assets/ui-icons.js";
18
18
  import { toast } from "/assets/ui-toast.js";
19
19
  import { confirmDialog } from "/assets/ui-confirm.js";
20
- import { confirmedWrite } from "./settings-confirm.js";
20
+ import { confirmedWrite, selfConfirmed } from "./settings-confirm.js";
21
21
  import { OrgCanvas } from "./fleet-canvas.js";
22
22
  import { labelOf } from "/assets/instance-nav.js";
23
23
  import { CacheReport } from "./fleet-cache.js";
@@ -320,7 +320,7 @@ function Config({ lease }) {
320
320
  // A change to access or the connection may need a fleet admin's confirmation (#1423): the shell follows it.
321
321
  try {
322
322
  const res = await confirmedWrite("/ui/config", { method: "POST", body, label: t("fleet.config"),
323
- onPending: () => { toast(t("app.pendingSent")); done(); } });
323
+ onPending: (_id, view) => { toast(t(selfConfirmed(view) ? "app.pendingSelfSent" : "app.pendingSent")); done(); } });
324
324
  r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
325
325
  } catch (err) { r = { error: err.message }; }
326
326
  done();
@@ -367,7 +367,7 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
367
367
  // said in a toast, and nothing here closes, refreshes or acts again.
368
368
  try {
369
369
  const res = await confirmedWrite(submit.path, { method: submit.method || "POST", body, label: title,
370
- onPending: () => { handedOver = true; toast(t("app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
370
+ onPending: (_id, view) => { handedOver = true; toast(t(selfConfirmed(view) ? "app.pendingSelfSent" : "app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
371
371
  r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
372
372
  } catch (err) { r = { error: err.message }; }
373
373
  if (r.error) { toast(r.error, false); if (!handedOver && lease.current()) setBusy(false); return; }
@@ -84,7 +84,8 @@ function IdentityColorCard() {
84
84
  /**
85
85
  * #1958 decision 5: Restore defaults — the host's appearance settings back to their defaults after a confirm that says
86
86
  * what it resets; this person's custom CSS turned off (its versions stay). Not settings, so never touched: the colours
87
- * people picked per instance, installed mods, uploaded effects and the fleet's card limits.
87
+ * people picked per instance (and where each agent's colours go, #2196), installed mods, uploaded effects and the
88
+ * fleet's card limits.
88
89
  */
89
90
  export async function restoreAppearanceDefaults() {
90
91
  setThemesShown(true);
@@ -11,7 +11,7 @@ import { appStore } from "/assets/app-store.js";
11
11
  import { t } from "/assets/app-i18n.js";
12
12
  import { toast } from "/assets/ui-toast.js";
13
13
  import "./settings-strings.js";
14
- import { api, confirmedWrite, newKey } from "./settings-confirm.js";
14
+ import { api, confirmedWrite, newKey, selfConfirmed } from "./settings-confirm.js";
15
15
 
16
16
  const WATCH_LIMIT_MS = 10 * 60_000;
17
17
  const POST_RETRIES = [1000, 2000, 4000];
@@ -114,7 +114,7 @@ async function perform(step, o) {
114
114
  if (step.request) {
115
115
  const { method, url, body } = step.request;
116
116
  return confirmedWrite(url, { method, body, key: step.key, label: step.label,
117
- onPending: (id) => { if (mine(o)) { step.pendingId = id; step.status = "waiting"; publish(); } } });
117
+ onPending: (id, view) => { if (mine(o)) { step.pendingId = id; step.status = selfConfirmed(view) ? "running" : "waiting"; publish(); } } });
118
118
  }
119
119
  if (step.connectionSecret) return rotateConnectionSecret(step, o);
120
120
  return { ok: false, body: { error: "unknown change" } };
@@ -129,7 +129,7 @@ async function rotateConnectionSecret(step, o) {
129
129
  if (!verified.ok) return verified;
130
130
  const applied = await confirmedWrite(`${base}/apply`, { method: "POST", key: step.key, label: step.label,
131
131
  body: { verification_id: verified.body && verified.body.verification_id, idempotency_key: step.key },
132
- onPending: (pid) => { if (mine(o)) { step.pendingId = pid; step.status = "waiting"; publish(); } } });
132
+ onPending: (pid, view) => { if (mine(o)) { step.pendingId = pid; step.status = selfConfirmed(view) ? "running" : "waiting"; publish(); } } });
133
133
  if (!applied.ok && applied.status !== 409) return applied;
134
134
  let result = applied.body || {};
135
135
  for (let n = 0; result.job_id && result.result === "applying" && n < 60; n++) {
@@ -33,6 +33,10 @@ export async function api(path, opts = {}) {
33
33
 
34
34
  /** A request id we can follow: a non-empty string (the server's are 32 hex characters). */
35
35
  const usableId = (v) => typeof v === "string" && v.length > 0;
36
+ /** #2170: a held request its own web-verified account confirmed (the server's answer says so): applying, no wait for chat. */
37
+ export function selfConfirmed(view) {
38
+ return !!view && !!view.confirmation && view.confirmation.kind === "web";
39
+ }
36
40
  /** The 202 that means "waiting for an admin", not "done". */
37
41
  export function isPendingResponse(res) {
38
42
  return !!res && res.status === 202 && !!res.body && res.body.result === "pending_confirmation" && !!res.body.pending_change
@@ -159,13 +163,14 @@ export async function attach() {
159
163
  * itself would have been: applied → { ok: true, body: what the write answered }, anything else → { ok: false } with
160
164
  * the reason. An accepted answer that cannot be read is { ok: false, ambiguous: true } (see isUnreadableAccept); a
161
165
  * network failure throws. Every other answer is returned as it is. `key` is the request's Idempotency-Key: pass the same one to
162
- * retry the same request. `onPending(id)` is told when it starts waiting.
166
+ * retry the same request. `onPending(id, view)` is told when it starts waiting — or, held and confirmed by its own
167
+ * web-verified account (selfConfirmed(view), #2170), applying.
163
168
  */
164
169
  export async function confirmedWrite(path, { method = "POST", body, key = newKey("w"), label, headers, onPending } = {}) {
165
170
  const res = await api(path, { method, headers: { ...(headers || {}), "Idempotency-Key": key }, body: body === undefined ? undefined : JSON.stringify(body) });
166
171
  if (isUnreadableAccept(res)) return { ok: false, status: 202, ambiguous: true, body: { error: "unreadable_confirmation_response" } };
167
172
  if (!isPendingResponse(res)) return res;
168
- if (onPending) onPending(res.body.pending_change.id);
173
+ if (onPending) onPending(res.body.pending_change.id, res.body.pending_change);
169
174
  const final = await track(res.body.pending_change, label);
170
175
  return outcomeResponse(final);
171
176
  }
@@ -3,7 +3,7 @@ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
3
3
  import { register, t } from "/assets/app-i18n.js";
4
4
  import { useStore } from "/assets/app-store.js";
5
5
  import { OWNER_EFFECT_PREF_KEYS } from "/assets/app-prefs.js";
6
- import { getAppEffects } from "./app-effects.js";
6
+ import { getAppEffects, wakeEffects } from "./effects-waker.js";
7
7
  import { ownerEffectsStore, watchOwnerEffects, refreshOwnerEffects, saveOwnerEffects } from "/assets/owner-effect-prefs.js";
8
8
  register("ownerEffects", {
9
9
  en: {
@@ -38,9 +38,11 @@ export function OwnerEffects({ catalog, busy = false }) {
38
38
  const state = useStore(ownerEffectsStore), [message, setMessage] = useState(null), life = useRef({ alive: false, seq: 0 }), [runtime, setRuntime] = useState(null);
39
39
  useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
40
  useEffect(() => {
41
- let live = true; const entry = getAppEffects();
41
+ // #2193: managing personal effects needs the runtime's view of them: it is woken here (it may still be asleep).
42
+ let live = true, entry = getAppEffects(), off = null, end = null;
42
43
  const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion }); };
43
- const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
+ const bind = () => { off = entry?.subscribe(pull) ?? null; end = entry?.onEnd(pull) ?? null; pull(); };
45
+ if (entry) bind(); else { pull(); void wakeEffects().then(() => { if (!live) return; entry = getAppEffects(); bind(); }); }
44
46
  return () => { live = false; off?.(); end?.(); };
45
47
  }, []);
46
48
  const reload = () => { getAppEffects()?.api?.changedOwner?.(); return refreshOwnerEffects(); };
@@ -54,7 +56,7 @@ export function OwnerEffects({ catalog, busy = false }) {
54
56
  const seq = ++life.current.seq; setMessage(null);
55
57
  const result = await saveOwnerEffects({ [key]: id });
56
58
  if (!life.current.alive || seq !== life.current.seq) return;
57
- if (result.ok || result.saved) getAppEffects()?.api?.changedOwner?.();
59
+ if (result.ok || result.saved) getAppEffects()?.api?.changedOwner?.(); // #2193: asleep, the saved slot wakes it (app-prefs)
58
60
  setMessage(text(result.ok ? "saved" : result.saved ? "savedRefresh" : "failed"));
59
61
  };
60
62
  return html`<section class="s-fx-owner" aria-label=${text(state.scope === "operator" ? "operatorTitle" : "title")}><h4>${text(state.scope === "operator" ? "operatorTitle" : "title")}</h4><p class="sub">${text(state.scope === "operator" ? "operator" : "person")}</p>
@@ -80,6 +80,7 @@ register("app", {
80
80
  signOutAll: "Sign out everywhere", devices: "Signed-in devices", expires: "Ends by {0} at the latest, or after {1} unused",
81
81
  failed: "That did not work — try again.", close: "Close", more: "More actions", menu: "Menu",
82
82
  pendingTitle: "Changes waiting for confirmation", pending_pending: "Waiting for confirmation", pending_applying: "Confirmed — applying…",
83
+ pendingWeb: "Confirmed by your account (this browser is web-verified). General is told.",
83
84
  pending_applied: "Confirmed and applied", pending_rejected: "Not applied", pending_expired: "Expired — nothing was changed",
84
85
  pending_stale: "Not applied — settings changed meanwhile", pending_failed: "Confirmed, but applying it failed",
85
86
  pendingChat: "A fleet admin confirms this in chat (General topic).", pendingHost: "Confirm it on the host: {0}", pendingLeft: "{0} left", withdraw: "Withdraw",
@@ -87,6 +88,7 @@ register("app", {
87
88
  opDone: "Settings applied", opRestart: "Settings saved — AgEnD needs a restart to use them", opPartial: "Some settings were not applied",
88
89
  opFailed: "Settings were not applied", openSettings: "Open Settings",
89
90
  pendingSent: "Sent for confirmation — a fleet admin confirms it in chat.",
91
+ pendingSelfSent: "Confirmed by your account (this browser is web-verified) — applying.",
90
92
  needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting",
91
93
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
92
94
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
@@ -113,6 +115,7 @@ register("app", {
113
115
  signOutAll: "全部登出", devices: "已登入的裝置", expires: "最晚 {0} 結束;閒置 {1} 也會結束",
114
116
  failed: "沒有成功,請再試一次。", close: "關閉", more: "更多動作", menu: "選單",
115
117
  pendingTitle: "等待確認的變更", pending_pending: "等待確認", pending_applying: "已確認——套用中…",
118
+ pendingWeb: "由你的帳號確認(這個瀏覽器已通過網頁驗證),也會通知 General。",
116
119
  pending_applied: "已確認並套用", pending_rejected: "未套用", pending_expired: "已逾時——沒有任何變更",
117
120
  pending_stale: "未套用——設定在這期間已變動", pending_failed: "已確認,但套用失敗",
118
121
  pendingChat: "由 fleet 管理員在聊天(General topic)中確認。", pendingHost: "請在主機上確認:{0}", pendingLeft: "剩 {0}", withdraw: "撤回",
@@ -120,6 +123,7 @@ register("app", {
120
123
  opDone: "設定已套用", opRestart: "設定已儲存——需要重新啟動 AgEnD 才會生效", opPartial: "部分設定沒有套用",
121
124
  opFailed: "設定沒有套用", openSettings: "開啟設定",
122
125
  pendingSent: "已送出確認——由 fleet 管理員在聊天中確認。",
126
+ pendingSelfSent: "已由你的帳號確認(這個瀏覽器已通過網頁驗證)——套用中。",
123
127
  needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理",
124
128
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
125
129
  needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_crashed: "已崩潰", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗",
@@ -43,6 +43,7 @@ export const SYNCED = {
43
43
  "widget.sidebar": { ls: "agend_widget_sidebar", ...json },
44
44
  // #1958: where identity colours show by default (#2056: name, bar and ring unless chosen), and the host's switch to turn them off.
45
45
  "color.show": { ls: "agend_color_show", ...json },
46
+ "theme.pageByInstance": { ls: "agend_theme_page", ...json }, // #2196
46
47
  "color.off": { ls: "agend_color_off", toLocal: (v) => (v === true ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
47
48
  };
48
49
  // #1629: fixed personal scope. A separate session-bound owner-effect store synchronizes these, never this host cache.
@@ -78,6 +79,15 @@ const inflight = new Map(); // key → writes sent and not answered yet
78
79
  let flushTimer = null;
79
80
  let writes = Promise.resolve(); // one write at a time, in the order they were made
80
81
  const listeners = new Set();
82
+ // #2193: whether the signed-in person has a personal effect slot chosen (their fx.* keys: in the page's data-prefs and
83
+ // in every host read, never in this host cache). The effects runtime is loaded only when there is something to play.
84
+ const ownerEffectsIn = (values) => !!values && OWNER_EFFECT_PREF_KEYS.some((k) => own(values, k) && values[k] != null && values[k] !== "");
85
+ let ownerFx = initial ? ownerEffectsIn(initial.values) : false;
86
+ const ownerFxListeners = new Set();
87
+ /** Whether the person has any personal effect (fx.page/header/sidebar/box/cursor) chosen, as this page last read. */
88
+ export function ownerEffectsChosen() { return ownerFx; }
89
+ /** Called when that changes. Returns the unsubscribe. */
90
+ export function onOwnerEffectsChanged(fn) { ownerFxListeners.add(fn); return () => ownerFxListeners.delete(fn); }
81
91
 
82
92
  // The host's values into the cache, now — before the sidebar, the tour or the first-run card read their keys. A key
83
93
  // the host does not have: the default, on a device in step with the host; this device's own (to upload) otherwise.
@@ -118,6 +128,8 @@ function take(body) {
118
128
  if (!isView(body) || (rev !== null && body.rev < rev)) return false;
119
129
  rev = body.rev;
120
130
  host = hostValues(body.values);
131
+ const fx = ownerEffectsIn(body.values);
132
+ if (fx !== ownerFx) { ownerFx = fx; for (const fn of [...ownerFxListeners]) { try { fn(fx); } catch { /* the rest still hear */ } } }
121
133
  return true;
122
134
  }
123
135
 
@@ -11,7 +11,8 @@ import { appStore, createStore, guestBadge, guestPage, useStore } from "./app-st
11
11
  import { navStore } from "./app-nav.js";
12
12
  import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, fleetEntryPath } from "./app-route.js";
13
13
  import { SessionMenu } from "./app-session.js";
14
- import { savePref } from "./app-prefs.js";
14
+ import { onPrefsChanged, savePref } from "./app-prefs.js";
15
+ import { applyPageTheme, pageNow, resolvedMode, themesStore } from "./app-themes.js";
15
16
  import { ErrorState, Skeleton } from "./ui-states.js";
16
17
  import { InstanceNav, RosterNav, namesStore, setNamesPrimary } from "./instance-nav.js";
17
18
  import { UsageRail, usageRailStore } from "./header-tools.js";
@@ -508,6 +509,7 @@ export function PendingChanges({ inline = false } = {}) {
508
509
  ${p.label ? html`<div class="pending-label">${p.label}</div>` : null}
509
510
  ${Array.isArray(p.summary) && p.summary.length ? html`<ul class="pending-summary">${p.summary.map((line, i) => html`<li key=${i}>${line}</li>`)}</ul>` : null}
510
511
  ${p.state === "pending" ? html`<p class="note">${p.confirmation && p.confirmation.kind === "host_cli" ? t("app.pendingHost", `agend settings confirm ${p.id}`) : t("app.pendingChat")}</p>` : null}
512
+ ${(p.state === "applying" || p.state === "applied") && p.confirmation && p.confirmation.kind === "web" ? html`<p class="note">${t("app.pendingWeb")}</p>` : null}
511
513
  ${p.state !== "pending" && p.state !== "applying" && p.state !== "applied" && p.outcome && p.outcome.message ? html`<p class="note">${p.outcome.message}</p>` : null}
512
514
  ${p.withdraw ? html`<div class="pending-actions"><button type="button" class="btn btn-sm" onClick=${p.withdraw}>${t("app.withdraw")}</button></div>` : null}
513
515
  </section>`)}</div>`;
@@ -557,9 +559,43 @@ function Outlet({ route, seq, panels }) {
557
559
  return html`<${C} route=${route} navKey=${`${routeKey(route)}|${seq}|${lang()}`} />`;
558
560
  }
559
561
 
562
+ /**
563
+ * #2196: the open agent's colours on the whole page (its theme's page section, or a person's choice), drawn on the
564
+ * shell in the same render that opens it — no fetch (the themes are on the page already), no flash, colours only, so
565
+ * no layout moves. Redrawn when the themes, the person's choices, the viewer's switch or the light/dark mode move.
566
+ */
567
+ function usePageTheme(ref, route) {
568
+ useStore(themesStore);
569
+ const [, bump] = useState(0);
570
+ useEffect(() => onPrefsChanged((changed) => { if (changed.has("theme.pageByInstance") || changed.has("fx.show")) bump((n) => n + 1); }), []);
571
+ useEffect(() => {
572
+ const T = globalThis.AgendTheme;
573
+ const off = T && typeof T.onChange === "function" ? T.onChange(() => bump((n) => n + 1)) : null;
574
+ let mq = null;
575
+ try { mq = globalThis.matchMedia ? globalThis.matchMedia("(prefers-color-scheme: light)") : null; } catch { mq = null; }
576
+ const onMq = () => bump((n) => n + 1);
577
+ if (mq && typeof mq.addEventListener === "function") mq.addEventListener("change", onMq);
578
+ return () => { if (typeof off === "function") off(); if (mq && typeof mq.removeEventListener === "function") mq.removeEventListener("change", onMq); };
579
+ }, []);
580
+ const mode = resolvedMode();
581
+ const p = pageNow(mode, route);
582
+ const key = p ? `${mode}|${JSON.stringify(p)}` : "";
583
+ useLayoutEffect(() => { applyPageTheme(ref.current, p, mode); }, [key]);
584
+ // #2199: the agent open now, as body[data-agent] — for a person's own custom CSS to follow (body[data-agent="x"]
585
+ // { --bg-sidebar: … }); set and cleared in the same render as the page colours, whatever the themes switch says.
586
+ const agent = openInstanceOf(route);
587
+ useLayoutEffect(() => {
588
+ const body = typeof document !== "undefined" ? document.body : null;
589
+ if (!body || !body.dataset) return;
590
+ if (agent) body.dataset.agent = agent; else delete body.dataset.agent;
591
+ }, [agent]);
592
+ }
593
+
560
594
  export function Shell({ panels, onNewInstance, viewOnly = false }) {
561
595
  const nav = useStore(navStore);
562
596
  const shell = useStore(shellStore);
597
+ const root = useRef(null);
598
+ usePageTheme(root, nav.route);
563
599
  const [, relang] = useState(0);
564
600
  // A language switch re-renders everything and gives the panel a new lease (its key includes the language).
565
601
  useEffect(() => onLang(() => relang(n => n + 1)), []);
@@ -574,7 +610,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
574
610
  }, []);
575
611
  const guest = guestPage(); // #1920: a guest's page — no operations, usage, pending changes or owner CSS
576
612
  const cls = ["shell", shell.collapsed ? "sb-collapsed" : "", shell.drawer ? "sb-open" : "", shell.keyboard ? "kb-open" : "", guest ? "guest" : ""].filter(Boolean).join(" ");
577
- return html`<div class=${cls}>
613
+ return html`<div ref=${root} class=${cls}>
578
614
  <a data-trust="navigation" class="skip" href="#main">${t("app.skip")}</a>
579
615
  <${Sidebar} route=${nav.route} onNewInstance=${onNewInstance} viewOnly=${viewOnly} />
580
616
  <div class="scrim" onClick=${closeDrawer} aria-hidden="true"></div>