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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) 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.js +10 -5
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +2 -2
  6. package/dist/backend/claude-code.js +5 -4
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/types.d.ts +5 -0
  9. package/dist/backend/types.js.map +1 -1
  10. package/dist/channel/mcp-tools.js +4 -0
  11. package/dist/channel/mcp-tools.js.map +1 -1
  12. package/dist/classic-channel-manager.d.ts +8 -3
  13. package/dist/classic-channel-manager.js +10 -3
  14. package/dist/classic-channel-manager.js.map +1 -1
  15. package/dist/delivery-outbox.js +5 -0
  16. package/dist/delivery-outbox.js.map +1 -1
  17. package/dist/fleet-manager.d.ts +68 -3
  18. package/dist/fleet-manager.js +247 -41
  19. package/dist/fleet-manager.js.map +1 -1
  20. package/dist/fleet-traffic.d.ts +38 -0
  21. package/dist/fleet-traffic.js +96 -0
  22. package/dist/fleet-traffic.js.map +1 -0
  23. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  24. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  25. package/dist/general-knowledge/skills/web-appearance/SKILL.md +39 -4
  26. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  27. package/dist/locale.js +6 -4
  28. package/dist/locale.js.map +1 -1
  29. package/dist/memory-hogs.d.ts +27 -0
  30. package/dist/memory-hogs.js +124 -0
  31. package/dist/memory-hogs.js.map +1 -0
  32. package/dist/outbound-handlers.d.ts +2 -0
  33. package/dist/outbound-handlers.js +40 -1
  34. package/dist/outbound-handlers.js.map +1 -1
  35. package/dist/outbound-schemas.d.ts +88 -0
  36. package/dist/outbound-schemas.js +50 -0
  37. package/dist/outbound-schemas.js.map +1 -1
  38. package/dist/public-web-gateway.js +1 -1
  39. package/dist/public-web-gateway.js.map +1 -1
  40. package/dist/tmux-manager.d.ts +5 -0
  41. package/dist/tmux-manager.js +16 -0
  42. package/dist/tmux-manager.js.map +1 -1
  43. package/dist/tool-permissions.js +4 -0
  44. package/dist/tool-permissions.js.map +1 -1
  45. package/dist/types.d.ts +1 -1
  46. package/dist/ui/app-effects.js +33 -9
  47. package/dist/ui/chat-effects.js +62 -4
  48. package/dist/ui/chat-strings.js +40 -4
  49. package/dist/ui/chat-thread.js +13 -5
  50. package/dist/ui/effect-trial-panel.js +19 -16
  51. package/dist/ui/fleet-canvas-layout.js +111 -0
  52. package/dist/ui/fleet-canvas.js +489 -0
  53. package/dist/ui/fleet-org.js +27 -12
  54. package/dist/ui/fleet-traffic.js +224 -0
  55. package/dist/ui/panel-chat.js +8 -2
  56. package/dist/ui/panel-details.js +2 -1
  57. package/dist/ui/panel-fleet.js +17 -5
  58. package/dist/ui/panel-settings.js +3 -2
  59. package/dist/ui/preview.js +3 -2
  60. package/dist/ui/settings-appearance.js +34 -18
  61. package/dist/ui/settings-dialogs.js +3 -6
  62. package/dist/ui/settings-effects.js +4 -3
  63. package/dist/ui/settings-learn.js +25 -0
  64. package/dist/ui/settings-model.js +6 -2
  65. package/dist/ui/settings-mods.js +4 -3
  66. package/dist/ui/settings-owner-effects.js +8 -8
  67. package/dist/ui/settings-strings.js +34 -38
  68. package/dist/ui/shared/app-i18n.js +2 -2
  69. package/dist/ui/shared/app-prefs.js +1 -1
  70. package/dist/ui/shared/app-shell.js +23 -1
  71. package/dist/ui/shared/app-store.js +12 -1
  72. package/dist/ui/shared/app-stream.js +2 -1
  73. package/dist/ui/shared/app.css +152 -10
  74. package/dist/ui/shared/effect-app-runtime.js +1 -1
  75. package/dist/ui/shared/effect-frame.js +10 -5
  76. package/dist/ui/shared/effect-geometry.js +24 -9
  77. package/dist/ui/shared/effect-policy.js +2 -1
  78. package/dist/ui/shared/effect-registry.js +71 -17
  79. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  80. package/dist/ui/shared/effect-trial-stage.js +15 -9
  81. package/dist/ui/shared/effect-trial.css +40 -0
  82. package/dist/ui/shared/identity-card.js +5 -2
  83. package/dist/ui/shared/identity-color.js +3 -3
  84. package/dist/ui/shared/instance-nav.js +20 -4
  85. package/dist/ui/shared/panel-view.js +2 -2
  86. package/dist/ui/shared/view-strings.js +16 -0
  87. package/dist/view-api.d.ts +21 -0
  88. package/dist/view-api.js +28 -0
  89. package/dist/view-api.js.map +1 -1
  90. package/dist/web-api.d.ts +5 -6
  91. package/dist/web-api.js +57 -10
  92. package/dist/web-api.js.map +1 -1
  93. package/dist/web-appearance-view.d.ts +10 -0
  94. package/dist/web-appearance-view.js +77 -8
  95. package/dist/web-appearance-view.js.map +1 -1
  96. package/dist/web-auth.js +1 -1
  97. package/dist/web-auth.js.map +1 -1
  98. package/dist/web-effect-trials.d.ts +2 -2
  99. package/dist/web-effect-trials.js +3 -3
  100. package/dist/web-effect-trials.js.map +1 -1
  101. package/dist/web-guest-access.d.ts +67 -0
  102. package/dist/web-guest-access.js +126 -0
  103. package/dist/web-guest-access.js.map +1 -0
  104. package/dist/web-identity-palette.d.ts +1 -1
  105. package/dist/web-identity-palette.js +2 -2
  106. package/dist/web-identity-palette.js.map +1 -1
  107. package/dist/web-mod-store.d.ts +5 -0
  108. package/dist/web-mod-store.js +6 -0
  109. package/dist/web-mod-store.js.map +1 -1
  110. package/dist/web-prefs.d.ts +8 -0
  111. package/dist/web-prefs.js +9 -0
  112. package/dist/web-prefs.js.map +1 -1
  113. package/dist/web-preview.d.ts +6 -2
  114. package/dist/web-preview.js +4 -2
  115. package/dist/web-preview.js.map +1 -1
  116. package/dist/web-session.d.ts +15 -7
  117. package/dist/web-session.js +25 -8
  118. package/dist/web-session.js.map +1 -1
  119. package/dist/web-settings-tools.d.ts +69 -0
  120. package/dist/web-settings-tools.js +247 -0
  121. package/dist/web-settings-tools.js.map +1 -0
  122. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ // #1918 phase 3: live cross-instance traffic on the org canvas (fleet-canvas.js) — what src/fleet-traffic.ts sends: the
2
+ // stream's `traffic` items, and GET /ui/traffic's snapshot when the tab opens (and on the public link, which has no
3
+ // stream, every POLL_MS while the page is shown — the user's decision 1). Ids are opaque; there is never any text.
4
+ //
5
+ // - The store keeps each message's latest state: queued (a dotted line), delivering (a dash and a pulse), delivered
6
+ // (the line stays FADE_MS while it fades, then goes); consumed, failed, uncertain or cancelled ends it at once (a
7
+ // failed or uncertain delivery is Needs you's to show, #1386). A finished message never comes back.
8
+ // - Edges are routed metro-style through the lanes the layout keeps empty (beside the columns and between card rows
9
+ // inside a box; the bus above each row of teams; the trunk at the left), so a line never crosses a card.
10
+ // - Near: one edge per (from, to, kind, phase), ×N when several share it. Far (zoomed out): one edge per team pair.
11
+ // A broadcast (one message per recipient, sharing an `op`) is a fan-out from its sender; more than BUNDLE_AT
12
+ // recipients, or far, it is one edge per recipient team, ×N (the user's decision 3). At most MAX_EDGES are drawn.
13
+ import { OC } from "./fleet-canvas-layout.js";
14
+
15
+ export const FADE_MS = 3000;
16
+ export const POLL_MS = 5000;
17
+ export const MAX_EDGES = 60;
18
+ export const BUNDLE_AT = 8;
19
+ export const FAR_K = 0.55;
20
+ const MAX_ITEMS = 400, MAX_DONE = 2000;
21
+ const LIVE = new Set(["queued", "delivering"]);
22
+ const ENDS = new Set(["consumed", "failed", "uncertain", "cancelled"]);
23
+ const KINDS = new Set(["task", "report", "query", "update", "send", "broadcast"]);
24
+ const mono = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
25
+
26
+ /** A traffic item as the server sends it, checked; null for anything else. */
27
+ export function itemOk(x) {
28
+ if (!x || typeof x !== "object") return null;
29
+ const { id, from, to, kind, state, op } = x;
30
+ if (typeof id !== "string" || !/^[0-9a-f]{16}$/.test(id) || typeof from !== "string" || !from || typeof to !== "string" || !to) return null;
31
+ if (!KINDS.has(kind) || !(LIVE.has(state) || state === "delivered" || ENDS.has(state))) return null;
32
+ return { id, from, to, kind, state, ...(typeof op === "string" && /^[0-9a-f]{16}$/.test(op) ? { op } : {}) };
33
+ }
34
+
35
+ /**
36
+ * The page's traffic: `apply(item, nowMs)` one stream item, `load(snapshot, nowMs)` a snapshot, `sweep(nowMs)` drops
37
+ * faded lines; each returns whether anything shown changed. `items()` what to draw: { …item, fading }.
38
+ */
39
+ export function createTrafficStore() {
40
+ const items = new Map(); // id → { item, fadeAt }
41
+ const done = new Set(); // ids that ended: a late item for one is ignored
42
+ const finish = (id) => {
43
+ items.delete(id);
44
+ done.add(id);
45
+ if (done.size > MAX_DONE) done.delete(done.values().next().value);
46
+ };
47
+ const store = {
48
+ apply(raw, now = mono()) {
49
+ const it = itemOk(raw);
50
+ if (!it || done.has(it.id)) return false;
51
+ const prev = items.get(it.id);
52
+ if (ENDS.has(it.state)) { const had = !!prev; finish(it.id); return had; }
53
+ if (prev && prev.item.state === "delivered") return false; // delivered never goes back
54
+ if (prev && prev.item.state === it.state) return false;
55
+ items.delete(it.id);
56
+ items.set(it.id, { item: it, fadeAt: it.state === "delivered" ? now + FADE_MS : null });
57
+ while (items.size > MAX_ITEMS) items.delete(items.keys().next().value);
58
+ return true;
59
+ },
60
+ /** A snapshot is the truth for what is in flight: anything live it no longer lists has ended (missed while away). */
61
+ load(snap, now = mono()) {
62
+ const list = snap && Array.isArray(snap.items) ? snap.items.map(itemOk).filter(Boolean) : [];
63
+ const listed = new Set(list.map((i) => i.id));
64
+ let changed = false;
65
+ for (const [id, e] of items) if (!listed.has(id) && LIVE.has(e.item.state)) { finish(id); changed = true; }
66
+ // Delivered, for a line drawn here: it fades now (with no stream, the snapshot is the only news of it). Any other
67
+ // finished message is not drawn (it finished up to two minutes ago), and an ended one goes.
68
+ for (const it of list) {
69
+ if (LIVE.has(it.state) || (it.state === "delivered" && items.has(it.id))) changed = store.apply(it, now) || changed;
70
+ else { const had = items.has(it.id); finish(it.id); changed = changed || had; }
71
+ }
72
+ return changed;
73
+ },
74
+ sweep(now = mono()) {
75
+ let changed = false;
76
+ for (const [id, e] of items) if (e.fadeAt !== null && now >= e.fadeAt) { finish(id); changed = true; }
77
+ return changed;
78
+ },
79
+ items: () => [...items.values()].map((e) => ({ ...e.item, fading: e.fadeAt !== null })),
80
+ fadingCount: () => [...items.values()].filter((e) => e.fadeAt !== null).length,
81
+ };
82
+ return store;
83
+ }
84
+
85
+ // ── Where a name is on the canvas ──
86
+
87
+ /** The team a name is drawn in (its first, if it is in two), null for General, undefined when it is not on the canvas. */
88
+ function teamOf(L, name) {
89
+ if (L.tops.some((c) => c.node.name === name)) return null;
90
+ const b = L.boxes.find((x) => x.nodes.some((n) => n.name === name));
91
+ return b ? b : undefined;
92
+ }
93
+ /** An end of a line: General's card, a member's card, or its folded team's box. */
94
+ function endOf(L, name) {
95
+ const top = L.tops.find((c) => c.node.name === name);
96
+ if (top) return { kind: "top", c: top, key: top.key };
97
+ const b = teamOf(L, name);
98
+ if (!b) return null;
99
+ if (b.folded) return { kind: "box", b, key: `box:${b.key}` };
100
+ const i = b.cells.findIndex((c) => c.node.name === name);
101
+ return { kind: "cell", b, i, c: b.cells[i], key: b.cells[i].key };
102
+ }
103
+
104
+ // ── Routing: lanes the layout keeps empty ──
105
+
106
+ const firstBusOf = (L) => Math.min(...L.boxes.map((b) => b.busY));
107
+ /** From an end up to its row's bus lane: the points, and the lane's y. `o` spreads lines sharing a lane. */
108
+ function outward(L, end, o) {
109
+ if (end.kind === "top") {
110
+ const x = end.c.x + end.c.w / 2 + Math.max(-end.c.w / 2 + 12, Math.min(end.c.w / 2 - 12, o * 2.4));
111
+ const bus = firstBusOf(L) + o;
112
+ return { pts: [{ x, y: end.c.y + end.c.h }, { x, y: bus }], bus };
113
+ }
114
+ const b = end.b;
115
+ if (end.kind === "box") {
116
+ const x = b.x + b.w / 2 + o * 1.5;
117
+ return { pts: [{ x, y: b.y }, { x, y: b.busY + o }], bus: b.busY + o };
118
+ }
119
+ const { PAD, GAP, NW, NH } = OC;
120
+ const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
121
+ const io = Math.max(-PAD / 2 + 3, Math.min(PAD / 2 - 3, o * 0.5));
122
+ const leftLane = b.x + PAD / 2 + io, rightLane = b.x + b.w - PAD / 2 + io;
123
+ let pts;
124
+ if (col === 0 || col === b.cols - 1) {
125
+ const left = col === 0, lane = left ? leftLane : rightLane, cy = card.y + NH / 2 + io;
126
+ pts = [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }, { x: lane, y: b.y }];
127
+ } else {
128
+ // A middle column: down into the gutter below its row, across to the nearer side lane.
129
+ const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 + io / 2, cx = card.x + NW / 2 + o;
130
+ const lane = cx - b.x < b.w / 2 ? leftLane : rightLane;
131
+ pts = [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }, { x: lane, y: b.y }];
132
+ }
133
+ const top = pts[pts.length - 1];
134
+ pts.push({ x: top.x, y: b.busY + o });
135
+ return { pts, bus: b.busY + o };
136
+ }
137
+ /** The points of a line from one end to the other: up to the bus, along it (and the trunk, between rows), down. */
138
+ export function route(L, a, z, o) {
139
+ const p = outward(L, a, o), q = outward(L, z, o);
140
+ const back = [...q.pts].reverse();
141
+ if (Math.abs(p.bus - q.bus) < 1) return [...p.pts, ...back];
142
+ const tx = OC.TRUNK - 12 - o;
143
+ return [...p.pts, { x: tx, y: p.bus }, { x: tx, y: q.bus }, ...back];
144
+ }
145
+ /** An orthogonal polyline as SVG path data, its corners rounded. */
146
+ export function pathOf(pts, r = 8) {
147
+ const q = pts.filter((pt, i) => !i || pt.x !== pts[i - 1].x || pt.y !== pts[i - 1].y);
148
+ let d = `M${q[0].x} ${q[0].y}`;
149
+ for (let i = 1; i < q.length - 1; i++) {
150
+ const a = q[i - 1], b = q[i], c = q[i + 1];
151
+ const r1 = Math.min(r, Math.hypot(b.x - a.x, b.y - a.y) / 2, Math.hypot(c.x - b.x, c.y - b.y) / 2);
152
+ const u = { x: Math.sign(a.x - b.x), y: Math.sign(a.y - b.y) }, v = { x: Math.sign(c.x - b.x), y: Math.sign(c.y - b.y) };
153
+ d += ` L${b.x + u.x * r1} ${b.y + u.y * r1} Q${b.x} ${b.y} ${b.x + v.x * r1} ${b.y + v.y * r1}`;
154
+ }
155
+ const last = q[q.length - 1];
156
+ return `${d} L${last.x} ${last.y}`;
157
+ }
158
+ /** Where a line's ×N goes: the middle of its longest straight run. */
159
+ export function badgeAt(pts) {
160
+ let best = pts[0], len = -1;
161
+ for (let i = 1; i < pts.length; i++) {
162
+ const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
163
+ if (l > len) { len = l; best = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 }; }
164
+ }
165
+ return best;
166
+ }
167
+
168
+ // ── Edges ──
169
+
170
+ /** The colour family of a kind: task, report, broadcast, and the rest (send, query, update). */
171
+ export const hue = (kind) => (kind === "task" || kind === "report" || kind === "broadcast" ? kind : "send");
172
+ const phaseOf = (it) => (it.fading ? "fading" : it.state === "queued" ? "queued" : "live");
173
+ const RANK = { live: 0, queued: 1, fading: 2 };
174
+
175
+ /**
176
+ * The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
177
+ * { key, hue, phase, n, from, to, d, pts, badge } — `from`/`to` the instance (or team) names it joins, for search.
178
+ */
179
+ export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
180
+ if (!L.boxes.length && L.tops.length < 2) return [];
181
+ const groups = new Map();
182
+ const add = (key, g) => { const cur = groups.get(key); if (cur) { cur.n += g.n; cur.names.push(...g.names); } else groups.set(key, g); };
183
+ const teamKey = (name) => { const t = teamOf(L, name); return t === null ? "\u0000general" : t ? t.key : undefined; };
184
+ const teamEnd = (name) => { const t = teamOf(L, name); return t === null ? endOf(L, name) : t ? { kind: "box", b: t, key: `box:${t.key}` } : null; };
185
+ // Broadcasts: one fan-out per op; big ones (or far) bundle per recipient team.
186
+ const byOp = new Map();
187
+ for (const it of items) if (it.kind === "broadcast" && it.op) { const l = byOp.get(it.op) || []; l.push(it); byOp.set(it.op, l); }
188
+ const bundled = new Set();
189
+ for (const list of byOp.values()) if (far || list.length > BUNDLE_AT) for (const it of list) bundled.add(it.id);
190
+ for (const it of items) {
191
+ const phase = phaseOf(it), names = [it.from, it.to];
192
+ if (far || bundled.has(it.id)) {
193
+ const ta = teamKey(it.from), tb = teamKey(it.to);
194
+ if (ta === tb) continue; // inside one team: not drawn far away
195
+ const k = far ? "team" : "broadcast";
196
+ const a = far ? teamEnd(it.from) : endOf(L, it.from), z = teamEnd(it.to);
197
+ if (!a || !z) continue;
198
+ add(`${k}|${a.key}>${z.key}|${phase === "fading" ? "fading" : "live"}`, { a, z, hue: k === "team" ? "team" : "broadcast", phase: phase === "fading" ? "fading" : phase, n: 1, names });
199
+ continue;
200
+ }
201
+ const a = endOf(L, it.from), z = endOf(L, it.to);
202
+ if (!a || !z || a.key === z.key) continue; // both in one folded team
203
+ add(`${a.key}>${z.key}|${hue(it.kind)}|${phase}`, { a, z, hue: hue(it.kind), phase, n: 1, names });
204
+ }
205
+ const list = [...groups.entries()].sort((x, y) => RANK[x[1].phase] - RANK[y[1].phase]).slice(0, max);
206
+ return list.map(([key, g], i) => {
207
+ const o = ((i % 7) - 3) * 7; // neighbours 7 px apart (within the ±22 px bus lane)
208
+ const pts = route(L, g.a, g.z, o);
209
+ 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 };
210
+ });
211
+ }
212
+
213
+ /** For the list: what each instance is sending now, in words ("task → quill"), live messages only. */
214
+ export function sendingBy(items) {
215
+ const out = new Map();
216
+ for (const it of items) {
217
+ if (it.fading) continue;
218
+ const l = out.get(it.from) || [];
219
+ const key = `${it.kind}>${it.to}>${it.state}`;
220
+ if (!l.some((x) => x.key === key)) l.push({ key, kind: it.kind, to: it.to, queued: it.state === "queued" });
221
+ out.set(it.from, l);
222
+ }
223
+ return out;
224
+ }
@@ -10,7 +10,7 @@ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-
10
10
  import { t } from "/assets/app-i18n.js";
11
11
  import { appStore, useStore } from "/assets/app-store.js";
12
12
  import { useLease } from "/assets/app-ctx.js";
13
- import { namesOf, namesStore } from "/assets/instance-nav.js";
13
+ import { modelText, namesOf, namesStore } from "/assets/instance-nav.js";
14
14
  import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
15
15
  import { detailsPath, textLinkSegments } from "/assets/app-route.js";
16
16
  import { InstanceSwitch } from "/assets/instance-switch.js";
@@ -281,7 +281,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
281
281
  // #1269 quick actions: the model and effort it runs, each a way to /model or /effort (the same pick list).
282
282
  const pick = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
283
283
  const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>
284
- ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", inst.model)} aria-label=${t("chat.chipModel", inst.model)} onClick=${() => pick("model")}>${inst.model}</button>` : null}
284
+ ${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}
285
285
  ${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}`;
286
286
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
287
287
  <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${html`<${InstanceSwitch} name=${name} current="chat" />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} /><${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onClose=${() => setDialog(null)} /><${HeaderTools} navKey=${navKey} /><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
@@ -312,6 +312,12 @@ function Thread({ name, th }) {
312
312
  const thread = createThread(list.current, scroller.current, {
313
313
  t, tf: t, isUser: (x) => store.isUser(x, name), onJump: setJump, onEmpty: setEmpty,
314
314
  setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
315
+ effectTrialStatus: x => getAppEffects()?.trialStatus(x) ?? "localAdmin",
316
+ fixEffectTrial: (x, reason) => {
317
+ if (reason === "busy") getAppEffects()?.stopTrial();
318
+ else if (reason === "localAdmin") location.href = "/signin?next=" + encodeURIComponent(location.pathname + location.search);
319
+ else navigate("/ui/settings?section=appearance");
320
+ },
315
321
  canTryEffect: x => getAppEffects()?.canTry(x) === true,
316
322
  startEffectTrial: x => { const seq = navStore.get().seq; void getAppEffects()?.startTrial(x).then(ok => { if (!ok && live && navStore.get().seq === seq && document.hasFocus?.() === true && document.visibilityState === "visible") toast(t("chat.effectTrialUnavailable")); }); },
317
323
  clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
@@ -14,6 +14,7 @@ import { PanelHeader, setTitle, statusClass, statusLabel, openDrawer } from "/as
14
14
  import { navigate } from "/assets/app-nav.js";
15
15
  import { settingsPath } from "/assets/app-route.js";
16
16
  import { InstanceSwitch } from "/assets/instance-switch.js";
17
+ import { modelText } from "/assets/instance-nav.js";
17
18
  import { HeaderTools } from "/assets/header-tools.js";
18
19
  import { Menu } from "/assets/ui-menu.js";
19
20
  import { themeMenuItems, themesStore } from "/assets/app-themes.js";
@@ -143,7 +144,7 @@ function DetailsView({ name, inst, navKey, exec, awaiting }) {
143
144
  <button type="button" class="btn btn-sm d-edit-settings" onClick=${editInSettings}>${t("chat.dEditInSettings")}</button></div></section>
144
145
  <section class="card"><h3>${t("chat.dRuntime")}</h3>
145
146
  ${row(t("chat.dBackend"), d.backend || "--")}
146
- ${row(t("chat.dModel"), `${d.model || sl.model?.display_name || "--"}${d.model_source === "live" ? "" : src(d.model_source)}`)}
147
+ ${row(t("chat.dModel"), `${(d.model && modelText(d)) || sl.model?.display_name || "--"}${d.model_source === "live" ? "" : src(d.model_source)}`)}
147
148
  ${d.effort ? row(t("chat.dEffort"), `${d.effort}${src(d.effort_source)}`) : null}
148
149
  ${row(t("chat.dCost"), `$${cost.toFixed(2)} ${t("chat.dCostNote")}`)}
149
150
  ${row(t("chat.dContext"), html`<span class="pct">${ctx != null ? `${Math.round(ctx)}%` : "--"}</span>${ctx != null ? bar(ctx, ctx > 80 ? "warn" : "") : null}`)}
@@ -18,14 +18,23 @@ 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
20
  import { confirmedWrite } from "./settings-confirm.js";
21
- import { OrgChart } from "./fleet-org.js";
21
+ import { OrgCanvas } from "./fleet-canvas.js";
22
22
  import { labelOf } from "/assets/instance-nav.js";
23
23
  import { CacheReport } from "./fleet-cache.js";
24
24
 
25
+ /** #2015: one of the largest processes as text: "python3 18.1 GB (dev-a)". */
26
+ export function memoryHogText(p) {
27
+ const size = p.rssBytes >= 1024 ** 3 ? `${(p.rssBytes / 1024 ** 3).toFixed(1)} GB` : `${Math.round(p.rssBytes / 1024 ** 2)} MB`;
28
+ const owner = p.owner === "instance" && p.instance ? p.instance
29
+ : t(p.owner === "fleet" ? "fleet.hostMemory_ownerFleet" : p.owner === "tmux" ? "fleet.hostMemory_ownerTmux" : "fleet.hostMemory_ownerOther");
30
+ return `${p.name} ${size} (${owner})`;
31
+ }
32
+
25
33
  register("fleet", {
26
34
  en: {
27
35
  hostMemory_elevated: "Host memory pressure: elevated — new agents start one at a time, at least 5 s apart",
28
36
  hostMemory_critical: "Host memory pressure: critical — new agents wait for memory to recover",
37
+ hostMemory_top: "Largest: {0}", hostMemory_ownerFleet: "the fleet", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "not an instance",
29
38
  title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
30
39
  docs: "Docs", docsNewTab: "Docs (opens in a new tab)",
31
40
  newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
@@ -57,6 +66,7 @@ register("fleet", {
57
66
  "zh-TW": {
58
67
  hostMemory_elevated: "主機記憶體壓力偏高:新的 agent 一次啟動一個,至少間隔 5 秒",
59
68
  hostMemory_critical: "主機記憶體壓力嚴重:新的 agent 會等記憶體恢復再啟動",
69
+ hostMemory_top: "最大:{0}", hostMemory_ownerFleet: "fleet 本身", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "不屬於任何 instance",
60
70
  title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
61
71
  docs: "文件", docsNewTab: "文件(在新分頁開啟)",
62
72
  newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
@@ -132,8 +142,10 @@ export function FleetPanel({ route, navKey }) {
132
142
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
133
143
  const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
134
144
  // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
135
- const { hostMemory } = useStore(appStore);
136
- const sub = hostMemory ? html`<span class=${`status host-mem ${hostMemory}`} role="status"><span class=${`dot ${hostMemory === "critical" ? "bad" : "warn"}`} aria-hidden="true"></span>${t(`fleet.hostMemory_${hostMemory}`)}</span>` : null;
145
+ const { hostMemory, hostMemoryTop } = useStore(appStore);
146
+ // #2015: and which processes hold the most, and whose — text only (never chat, #1712).
147
+ const top = hostMemory && hostMemoryTop?.length ? t("fleet.hostMemory_top", hostMemoryTop.map(memoryHogText).join(" · ")) : "";
148
+ const sub = hostMemory ? html`<span class=${`status host-mem ${hostMemory}`} role="status"><span class=${`dot ${hostMemory === "critical" ? "bad" : "warn"}`} aria-hidden="true"></span>${t(`fleet.hostMemory_${hostMemory}`)}${top ? html`<span class="host-mem-top">${top}</span>` : null}</span>` : null;
137
149
  return html`<div data-effect-panel="fleet" class="panel p-fleet">
138
150
  <${PanelHeader} title=${t("fleet.title")} sub=${sub}><${DocsLink} /><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
139
151
  <nav data-trust="panel-navigation" class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
@@ -255,11 +267,11 @@ function Teams({ lease }) {
255
267
  ${editing ? html`<${CreateTeam} team=${editing} onClose=${() => setEditing(null)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
256
268
  }
257
269
 
258
- /** #1389: the org chart — its structure read once per opening, its live state from the app store (fleet-org.js). */
270
+ /** #1389: the org chart — its structure read once per opening, its live state from the app store. #1918: a canvas, or the list (fleet-canvas.js). */
259
271
  function Org({ lease }) {
260
272
  const state = useLoad(lease, "/ui/org", 0);
261
273
  const app = useStore(appStore);
262
- return html`<${Loaded} state=${state}>${(org) => html`<${OrgChart} org=${org} app=${app} />`}</${Loaded}>`;
274
+ return html`<${Loaded} state=${state}>${(org) => html`<${OrgCanvas} org=${org} app=${app} lease=${lease} />`}</${Loaded}>`;
263
275
  }
264
276
 
265
277
  /** #1468: the prompt-cache analysis — one read per opening and per window chosen; Refresh reads again (fleet-cache.js). */
@@ -20,6 +20,7 @@ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
20
20
  import { Icon } from "/assets/ui-icons.js";
21
21
  import { Menu } from "/assets/ui-menu.js";
22
22
  import { toast } from "/assets/ui-toast.js";
23
+ import { groupLabel, modelText } from "/assets/instance-nav.js";
23
24
  import "./settings-strings.js";
24
25
  import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
25
26
  import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
@@ -87,7 +88,7 @@ async function loadLive(lease) {
87
88
  if (!lease.current()) return null;
88
89
  const live = {}, classicLive = {};
89
90
  if (f.ok && f.body && Array.isArray(f.body.instances)) for (const i of f.body.instances) {
90
- live[i.name] = { status: i.status, state: i.state, backend: i.backend, model: i.model, model_display: i.model_display, effort: i.effort, effort_supported: i.effort_supported };
91
+ live[i.name] = { status: i.status, state: i.state, backend: i.backend, model: i.model, model_display: modelText(i, i.model_display), effort: i.effort, effort_supported: i.effort_supported };
91
92
  if (i.classic) classicLive[i.name] = { status: i.status, state: i.state };
92
93
  }
93
94
  // /api/fleet carries live state for ClassicBot rooms; the profiles are the fallback for one not listed there yet.
@@ -473,7 +474,7 @@ function Agents({ ctx, search, openDialog }) {
473
474
  return html`<section key=${group} class="s-group">
474
475
  <button type="button" class="s-group-head" aria-expanded=${folded ? "false" : "true"}
475
476
  onClick=${() => setCollapsed((c) => { const n = new Set(c); n.has(group) ? n.delete(group) : n.add(group); return n; })}>
476
- <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${group}</span><span class="count">${names.length}</span></button>
477
+ <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${groupLabel(group)}</span><span class="count">${names.length}</span></button>
477
478
  ${folded ? null : names.map((name) => {
478
479
  const inst = insts[name];
479
480
  const st = (ctx.live[name] && ctx.live[name].status) || "stopped";
@@ -100,7 +100,7 @@
100
100
  notify();
101
101
  return true;
102
102
  }
103
- if (j && (j.code === "publicLinkPreviewFailed" || j.code === "publicLink" || j.code === "fleetOff")) {
103
+ if (j && (j.code === "publicLinkPreviewFailed" || j.code === "publicLink" || j.code === "fleetOff" || j.code === "listenFailed" || j.code === "notListening")) {
104
104
  cfg.reasonCode = j.code; cfg.reason = ""; cfg.detail = typeof j.detail === "string" ? j.detail : "";
105
105
  notify();
106
106
  return true;
@@ -191,7 +191,8 @@
191
191
  if (!cfg.previewOrigin || !cfg.boot) {
192
192
  // The server's reason, in the page's language when the server named it (a code), else as the server wrote it.
193
193
  // #1723: a failed public-link preview names why ({0} is the reason, in the page's words).
194
- var arg = cfg.reasonCode === "publicLinkPreviewFailed" ? say("pvFail_" + (cfg.detail || "unknown"), Object.prototype.hasOwnProperty.call(FAIL_EN, cfg.detail) ? FAIL_EN[cfg.detail] : "an unexpected error") : cfg.dashboardOrigin;
194
+ var arg = cfg.reasonCode === "publicLinkPreviewFailed" ? say("pvFail_" + (cfg.detail || "unknown"), Object.prototype.hasOwnProperty.call(FAIL_EN, cfg.detail) ? FAIL_EN[cfg.detail] : "an unexpected error")
195
+ : cfg.reasonCode === "listenFailed" ? cfg.detail : cfg.dashboardOrigin;
195
196
  var english = cfg.reasonCode === "publicLinkPreviewFailed" ? "Previews are unavailable on this public link: {0}. The link itself keeps working."
196
197
  : cfg.reason || "Previews are not available here.";
197
198
  var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, english, arg) : "";
@@ -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
  }
@@ -17,7 +17,7 @@ import {
17
17
  ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
18
18
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
19
19
  chLabel, hasOwn, impactOf, nonNegative, nonNegativeInteger, positive, replyGuardSupported, sameValue, visibilityDefault,
20
- webEchoIsGroup, worstImpact,
20
+ classicWebEchoEffective, worstImpact,
21
21
  } from "./settings-model.js";
22
22
 
23
23
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
@@ -557,7 +557,7 @@ export function ClassicDialog({ room, ctx, onClose }) {
557
557
  backend: c.backend || inherited.backend, model: c.model || "",
558
558
  auto_pause_after: String(c.auto_pause_after ?? inherited.auto_pause_after), tool_progress: c.tool_progress ?? inherited.tool_progress,
559
559
  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),
560
+ collab: !!c.collab, web_echo: classicWebEchoEffective(c, fleet), context_lines: String(c.context_lines ?? cd.context_lines ?? 5),
561
561
  inherit: { auto_pause_after: !hasOwn(c, "auto_pause_after"), tool_progress: !hasOwn(c, "tool_progress"), reply_completion_guard: !hasOwn(c, "reply_completion_guard") },
562
562
  }));
563
563
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
@@ -580,12 +580,9 @@ export function ClassicDialog({ room, ctx, onClose }) {
580
580
  auto_pause_after: hasOwn(c, "auto_pause_after") ? c.auto_pause_after : null,
581
581
  tool_progress: hasOwn(c, "tool_progress") ? c.tool_progress : null,
582
582
  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,
583
+ collab: !!c.collab, context_lines: c.context_lines ?? cd.context_lines ?? 5, web_echo: classicWebEchoEffective(c, fleet),
584
584
  });
585
585
  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
586
  if (!lease.current()) return;
590
587
  ctx.stageClassic(c, patch);
591
588
  onClose();
@@ -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
+ }