@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.14
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.
- package/README.md +6 -0
- package/README.zh-TW.md +6 -0
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/fleet-manager.d.ts +36 -1
- package/dist/fleet-manager.js +135 -43
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +21 -3
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
- package/dist/outbound-schemas.d.ts +14 -1
- package/dist/outbound-schemas.js +8 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +4 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/restart-progress.d.ts +16 -1
- package/dist/restart-progress.js +74 -15
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.js +4 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -1
- package/dist/settings-confirmation.js +2 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +2 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +11 -0
- package/dist/settings-http-confirmation.js +10 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/topic-commands.d.ts +10 -0
- package/dist/topic-commands.js +27 -15
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/effects-waker.js +70 -0
- package/dist/ui/fleet-canvas-layout.js +10 -4
- package/dist/ui/fleet-canvas.js +32 -2
- package/dist/ui/fleet-route-scene.js +207 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-traffic.js +27 -11
- package/dist/ui/fleet-usage.js +35 -1
- package/dist/ui/panel-chat.js +51 -24
- package/dist/ui/panel-fleet.js +3 -3
- package/dist/ui/settings-appearance.js +2 -1
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/shared/app-i18n.js +4 -0
- package/dist/ui/shared/app-prefs.js +12 -0
- package/dist/ui/shared/app-shell.js +38 -2
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +38 -3
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +11 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/web-authorize.js +47 -0
- package/dist/web-api.d.ts +0 -3
- package/dist/web-api.js +80 -14
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance.js +3 -1
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-effect.d.ts +1 -1
- package/dist/web-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.js.map +1 -0
- package/dist/web-owner-css-store.d.ts +3 -2
- package/dist/web-owner-css-store.js +9 -4
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-prefs.js +13 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-settings-tools.d.ts +5 -0
- package/dist/web-settings-tools.js +34 -6
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/package.json +2 -1
package/dist/ui/panel-chat.js
CHANGED
|
@@ -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 {
|
|
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";
|
|
@@ -26,26 +26,44 @@ import { confirmDialog } from "/assets/ui-confirm.js";
|
|
|
26
26
|
import { savePref } from "/assets/app-prefs.js";
|
|
27
27
|
import { applyTheme, clearAmbient, resolvedMode, themeFor, themeImageUrl, themeMenuItems, themesStore } from "/assets/app-themes.js";
|
|
28
28
|
import { colorMenuItem } from "/assets/identity-color.js";
|
|
29
|
-
import { IdentityCard, useCard } from "/assets/identity-card.js";
|
|
29
|
+
import { brokenPictures, cardFoldable, 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
|
|
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; its picture and bio can be folded away (the banner stays) — this
|
|
34
|
+
// device's choice too, kept the same way. The user (2026-10-20): no control on the card — one card button in the header
|
|
35
|
+
// (a guest's and the public link's header too: they have no ⋯ menu), pressed-looking while the card is shown, opens a
|
|
36
|
+
// small menu of two checkboxes: Show card, and Show picture and bio (disabled while the card is hidden, or when it cannot
|
|
37
|
+
// fold: no banner, or nothing on its row).
|
|
33
38
|
const CHAT_CARD_KEY = "agend_chat_card";
|
|
34
|
-
const
|
|
39
|
+
const CHAT_CARD_ROW_KEY = "agend_chat_card_row";
|
|
40
|
+
const readKey = (k, v) => { try { return localStorage.getItem(k) === v; } catch { return false; } };
|
|
41
|
+
const writeKey = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch { /* this page only */ } };
|
|
42
|
+
/** Shared by the header's Show card and the card itself, so the two never disagree. */
|
|
43
|
+
export const chatCardPrefs = createStore({ open: readKey(CHAT_CARD_KEY, "open"), rowHidden: readKey(CHAT_CARD_ROW_KEY, "hidden") });
|
|
44
|
+
export const setChatCardOpen = (open) => { writeKey(CHAT_CARD_KEY, open ? "open" : null); chatCardPrefs.set((s) => ({ ...s, open })); };
|
|
45
|
+
export const setChatCardRowHidden = (rowHidden) => { writeKey(CHAT_CARD_ROW_KEY, rowHidden ? "hidden" : null); chatCardPrefs.set((s) => ({ ...s, rowHidden })); };
|
|
35
46
|
function ChatCard({ name, card }) {
|
|
36
|
-
const
|
|
37
|
-
if (!card) return null;
|
|
38
|
-
|
|
39
|
-
|
|
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}
|
|
47
|
+
const prefs = useStore(chatCardPrefs);
|
|
48
|
+
if (!card || !prefs.open) return null;
|
|
49
|
+
return html`<div data-trust="card" class="chat-card open">
|
|
50
|
+
<${IdentityCard} name=${name} card=${card} compact=${true} rowHidden=${prefs.rowHidden} />
|
|
45
51
|
</div>`;
|
|
46
52
|
}
|
|
53
|
+
/** The header's card menu: ☑ Show card, ☑ Show picture and bio. */
|
|
54
|
+
function CardMenu({ card }) {
|
|
55
|
+
const prefs = useStore(chatCardPrefs);
|
|
56
|
+
useStore(brokenPictures); // a banner that failed to load: the card cannot fold, the item greys out
|
|
57
|
+
if (!card) return null;
|
|
58
|
+
const items = [
|
|
59
|
+
{ key: "card", checkbox: true, checked: prefs.open, label: t("card.show"), onSelect: () => setChatCardOpen(!prefs.open) },
|
|
60
|
+
{ key: "row", checkbox: true, checked: !prefs.rowHidden && cardFoldable(card), disabled: !prefs.open || !cardFoldable(card), label: t("card.showRow"),
|
|
61
|
+
onSelect: () => setChatCardRowHidden(!prefs.rowHidden) },
|
|
62
|
+
];
|
|
63
|
+
return html`<${Menu} items=${items} label=${t("card.menu")} icon="idcard" cls="chat-card-menu" active=${prefs.open} />`;
|
|
64
|
+
}
|
|
47
65
|
import "./chat-strings.js";
|
|
48
|
-
import { confirmedWrite } from "./settings-confirm.js";
|
|
66
|
+
import { confirmedWrite, selfConfirmed } from "./settings-confirm.js";
|
|
49
67
|
import { createChatStore } from "./chat-store.js";
|
|
50
68
|
import { createThread, msgKey } from "./chat-thread.js";
|
|
51
69
|
import { createEmojiIndex } from "./chat-emojis.js";
|
|
@@ -59,7 +77,7 @@ import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
|
|
|
59
77
|
import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
|
|
60
78
|
import { setFollowsReducedMotion } from "/assets/fx-motion.js";
|
|
61
79
|
import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
|
|
62
|
-
import {
|
|
80
|
+
import { startEffectsWaker, getAppEffects, wakeEffects, effectsLoaded, onEffectsAwake, chatHasEffects } from "./effects-waker.js";
|
|
63
81
|
|
|
64
82
|
const R = () => globalThis.AgendChatRender;
|
|
65
83
|
const P = () => globalThis.AgendPreview;
|
|
@@ -119,8 +137,9 @@ export function boot({ stream, boot: bootData, deps = {} }) {
|
|
|
119
137
|
}
|
|
120
138
|
// #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
|
|
121
139
|
installTour();
|
|
122
|
-
// #2118: viewing current in-scope Chat effects needs no management or personal-slot authority.
|
|
123
|
-
|
|
140
|
+
// #2118: viewing current in-scope Chat effects needs no management or personal-slot authority. #2193: the runtime
|
|
141
|
+
// itself loads only when there is something to play (effects-waker.js).
|
|
142
|
+
startEffectsWaker({ store });
|
|
124
143
|
return store;
|
|
125
144
|
}
|
|
126
145
|
|
|
@@ -276,6 +295,8 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
276
295
|
else if (r && r.held) toast(r.message);
|
|
277
296
|
else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
|
|
278
297
|
};
|
|
298
|
+
// #2193: this chat's Canvas Worker placements (its status row's effect_chat_v): none, no effects code runs for it.
|
|
299
|
+
const fxHere = chatHasEffects(name);
|
|
279
300
|
const items = [
|
|
280
301
|
// #1523 N2: Details is a page now (the Fleet side of this instance), not a dialog.
|
|
281
302
|
{ key: "details", label: t("chat.details"), icon: "info", onSelect: () => navigate(detailsPath(name)) },
|
|
@@ -301,7 +322,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
301
322
|
${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
323
|
${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
324
|
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)} />`}
|
|
325
|
+
<${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}<${CardMenu} card=${card} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
|
|
305
326
|
<${ChatCard} key=${name} name=${name} card=${card} />
|
|
306
327
|
<${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
|
|
307
328
|
<div class="chat-split" ref=${split}>
|
|
@@ -385,7 +406,8 @@ function threadEntry(name, doc) {
|
|
|
385
406
|
const thread = createThread(list, doc.createElement("div"), {
|
|
386
407
|
t, tf: t, isUser: (x) => store.isUser(x, name), onJump: (j) => e.sink?.setJump(j), onEmpty: (v) => e.sink?.setEmpty(v),
|
|
387
408
|
setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
|
|
388
|
-
|
|
409
|
+
// #2193: an offer shown while the effects runtime sleeps wakes it; until it answers, the offer is being checked.
|
|
410
|
+
effectTrialStatus: x => { const fx = getAppEffects(); if (fx) return fx.trialStatus(x); if (!effectsLoaded()) { void wakeEffects(); return "checking"; } return "localAdmin"; },
|
|
389
411
|
effectTrialFailure: x => getAppEffects()?.trialFailure(x),
|
|
390
412
|
fixEffectTrial: (x, reason) => {
|
|
391
413
|
if (reason === "reducedMotion") setFollowsReducedMotion(false);
|
|
@@ -423,7 +445,12 @@ function follow(name, thread) {
|
|
|
423
445
|
else if (kind === "sent") thread.jumpLatest();
|
|
424
446
|
}));
|
|
425
447
|
offs.push(P() ? P().onChange(() => thread.refreshCards()) : () => {});
|
|
426
|
-
|
|
448
|
+
// #2193: the runtime may start later (an offer shown wakes it): its trial updates are followed from then.
|
|
449
|
+
let trialOff = null;
|
|
450
|
+
const watchTrial = () => { trialOff = getAppEffects()?.subscribeTrial(() => thread.render(store.state.msgs[name] || [])) ?? null; };
|
|
451
|
+
watchTrial();
|
|
452
|
+
if (!trialOff) offs.push(onEffectsAwake(() => { watchTrial(); thread.render(store.state.msgs[name] || []); }));
|
|
453
|
+
offs.push(() => trialOff?.());
|
|
427
454
|
offs.push(panelStore.subscribe(() => thread.refreshCards())); // a card shows whether the panel has its block
|
|
428
455
|
return () => { for (const off of offs) off(); };
|
|
429
456
|
}
|
|
@@ -493,8 +520,8 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
|
|
|
493
520
|
${s.sendFailures[name] ? html`<div class="failed-send send-failure" role="alert"><span>${s.sendFailures[name].error}</span>
|
|
494
521
|
<button type="button" class="btn btn-sm" disabled=${!store.canRetrySend(name)} onClick=${() => store.retrySend(name)}>${t("chat.retrySend")}</button></div>` : null}
|
|
495
522
|
<${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
|
|
496
|
-
<
|
|
497
|
-
|
|
523
|
+
<div class="composer-row">${guestPage() ? html`<span class="guest-note" title=${t("chat.guestNote")}>${t("chat.guestNote")}</span>` : html`<${ComposerAuth} />`}<span
|
|
524
|
+
class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</span></div>
|
|
498
525
|
</div></div>`;
|
|
499
526
|
}
|
|
500
527
|
|
|
@@ -679,7 +706,7 @@ function DeleteDialog({ name, onClose }) {
|
|
|
679
706
|
// navigates anything (the person may be anywhere by then).
|
|
680
707
|
try {
|
|
681
708
|
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(); } } });
|
|
709
|
+
onPending: (_id, view) => { handedOver = true; toast(t(selfConfirmed(view) ? "app.pendingSelfSent" : "app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
|
|
683
710
|
r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
|
|
684
711
|
} catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
|
|
685
712
|
if (r && r.error) { toast(r.error, false); if (!handedOver && lease.current()) setBusy(false); return; }
|
package/dist/ui/panel-fleet.js
CHANGED
|
@@ -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
|
|
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 "./
|
|
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
|
-
|
|
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)
|
|
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>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// #1629 (first design §5, r2 (b)(d)): agents' web themes on the page. Each applies to THAT agent's own chat surface
|
|
2
|
-
// (and its avatar ring)
|
|
2
|
+
// (and its avatar ring) — and, #2196, when the agent (theme.page.on) or a person (theme.pageByInstance) says so, its
|
|
3
|
+
// colours on the whole page while that agent's Chat, View, Log or Details is open: colour tokens only, on the shell
|
|
4
|
+
// (applyPageTheme); AgEnD's status colours, fonts, pictures and layout stay the page's own. What the host sends is
|
|
3
5
|
// canonical already; this checks it again (a value that fails is dropped, never passed on) and applies it through the
|
|
4
6
|
// CSSOM (custom properties) and fixed class tokens. No style attribute, no CSS text, no url() built in CSS.
|
|
5
7
|
// Not shown: with "Show agents' themes, effects and widgets" off (fx.show, synced), and in safe mode (?safe=1).
|
|
6
8
|
import { appStore, createStore } from "./app-store.js";
|
|
9
|
+
import { navStore } from "./app-nav.js";
|
|
7
10
|
import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
|
|
8
11
|
import { safeMode } from "./owner-css.js";
|
|
9
12
|
import { t } from "./app-i18n.js";
|
|
@@ -86,9 +89,110 @@ export function themeFor(name) {
|
|
|
86
89
|
out.background = { image: bg.image, fit: bg.fit, opacity: bg.opacity };
|
|
87
90
|
}
|
|
88
91
|
if (labelOk(t.workingLabel)) out.workingLabel = t.workingLabel;
|
|
92
|
+
if (t.page && typeof t.page === "object") {
|
|
93
|
+
const sb = {};
|
|
94
|
+
if (t.page.sidebar && typeof t.page.sidebar === "object") for (const m of ["light", "dark"]) if (typeof t.page.sidebar[m] === "string" && COLOR.test(t.page.sidebar[m])) sb[m] = t.page.sidebar[m];
|
|
95
|
+
out.page = { on: t.page.on === true, sidebar: sb };
|
|
96
|
+
}
|
|
89
97
|
return out;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
// ── #2196: an agent's colours on the whole page ──
|
|
101
|
+
|
|
102
|
+
/** The token defaults (tokens.css; web-theme.ts THEME_DEFAULTS, pinned by a test) a theme's palette goes over. */
|
|
103
|
+
const PAGE_DEFAULTS = {
|
|
104
|
+
dark: { bg: "#212121", surface: "#2f2f2f", text: "#ececec", text2: "#b4b4b4", accent: "#7ab7ff" },
|
|
105
|
+
light: { bg: "#ffffff", surface: "#f4f4f4", text: "#0d0d0d", text2: "#5d5d5d", accent: "#0a62c2" },
|
|
106
|
+
};
|
|
107
|
+
/** AgEnD's status colours (tokens.css; web-theme.ts STATUS_COLORS, pinned by a test): never themed, always readable. */
|
|
108
|
+
export const STATUS_COLORS = {
|
|
109
|
+
dark: { ok: "#4cc38a", warn: "#e3b341", danger: "#ff8a80", dangerFill: "#c9372c", focus: "#7ab7ff" },
|
|
110
|
+
light: { ok: "#1a7f37", warn: "#8a5a00", danger: "#c4222b", dangerFill: "#c4222b", focus: "#0a62c2" },
|
|
111
|
+
};
|
|
112
|
+
const PAGE_PROPS = { bg: "--bg", sidebar: "--bg-sidebar", surface: "--surface", text: "--text", text2: "--text-2", accent: "--accent" };
|
|
113
|
+
const DERIVED_PROPS = ["--text-3", "--surface-2", "--border", "--border-strong", "--accent-dim", "--on-accent"];
|
|
114
|
+
function lum(hex) {
|
|
115
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4));
|
|
116
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
117
|
+
}
|
|
118
|
+
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
|
|
119
|
+
const mix = (a, b, f) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - f) + parseInt(b.slice(i, i + 2), 16) * f).toString(16).padStart(2, "0")).join("")}`;
|
|
120
|
+
|
|
121
|
+
/** The instance a page is about — Chat, View, Log or Details of it (app-shell.js openInstanceOf) — or null. */
|
|
122
|
+
function openOf(route) {
|
|
123
|
+
return route && ["chat", "view", "log", "details"].includes(route.panel) && typeof route.instance === "string" ? route.instance : null;
|
|
124
|
+
}
|
|
125
|
+
/** A person's choice for `name`: "page" (the whole page), "chat" (only its chat) or null (the agent's own). */
|
|
126
|
+
export function pageChoice(name) {
|
|
127
|
+
const f = prefInForce("theme.pageByInstance");
|
|
128
|
+
const m = f && f.value;
|
|
129
|
+
return m && typeof m === "object" && own(m, name) && (m[name] === "page" || m[name] === "chat") ? m[name] : null;
|
|
130
|
+
}
|
|
131
|
+
/** Set it ("agent" clears it), kept with the host's preferences like the identity colour a person picks. */
|
|
132
|
+
export function setPageChoice(name, choice) {
|
|
133
|
+
const f = prefInForce("theme.pageByInstance");
|
|
134
|
+
const cur = f && f.value && typeof f.value === "object" ? f.value : {};
|
|
135
|
+
const keep = Object.keys(cur).filter((k) => own(cur, k) && k !== name && (cur[k] === "page" || cur[k] === "chat")).map((k) => [k, cur[k]]);
|
|
136
|
+
if (choice === "page" || choice === "chat") keep.push([name, choice]);
|
|
137
|
+
savePref("theme.pageByInstance", keep.length ? Object.fromEntries(keep) : null);
|
|
138
|
+
// In force on this page at once: what draws a theme follows this store (another device's change: onPrefsChanged).
|
|
139
|
+
themesStore.set({ pageRev: (themesStore.get().pageRev || 0) + 1 });
|
|
140
|
+
}
|
|
141
|
+
/** `name`'s theme when its colours go on the whole page (the person's choice, else the agent's), else null. */
|
|
142
|
+
export function pageThemeFor(name) {
|
|
143
|
+
if (!name) return null;
|
|
144
|
+
const theme = themeFor(name);
|
|
145
|
+
if (!theme) return null;
|
|
146
|
+
const choice = pageChoice(name);
|
|
147
|
+
return choice === "page" || (choice !== "chat" && theme.page && theme.page.on) ? theme : null;
|
|
148
|
+
}
|
|
149
|
+
/** The page's colours for `mode`: the theme's palette over the defaults, and its sidebar (else its background). */
|
|
150
|
+
export function pagePalette(theme, mode) {
|
|
151
|
+
const p = { ...PAGE_DEFAULTS[mode], ...(theme.accent ? { accent: theme.accent } : {}), ...((theme.colors && theme.colors[mode]) || {}) };
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const k of Object.keys(PAGE_DEFAULTS[mode])) out[k] = p[k];
|
|
154
|
+
out.sidebar = (theme.page && theme.page.sidebar && theme.page.sidebar[mode]) || out.bg;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether a page palette reads (web-theme.ts's checks, again here: a person may put an agent's colours on the page
|
|
159
|
+
* though the agent never asked): text and secondary text 4.5:1 and the accent 3:1 on the background, surface and
|
|
160
|
+
* sidebar; every status colour 3:1 on the sidebar and the background.
|
|
161
|
+
*/
|
|
162
|
+
export function pageReadable(p, mode) {
|
|
163
|
+
if (!p || !Object.values(p).every((c) => typeof c === "string" && COLOR.test(c))) return false;
|
|
164
|
+
for (const bg of [p.bg, p.surface, p.sidebar]) {
|
|
165
|
+
if (ratio(p.text, bg) < 4.5 - 1e-9 || ratio(p.text2, bg) < 4.5 - 1e-9) return false;
|
|
166
|
+
}
|
|
167
|
+
if (ratio(p.accent, p.bg) < 3 - 1e-9 || ratio(p.accent, p.sidebar) < 3 - 1e-9) return false;
|
|
168
|
+
return Object.values(STATUS_COLORS[mode]).every((c) => ratio(c, p.sidebar) >= 3 - 1e-9 && ratio(c, p.bg) >= 3 - 1e-9);
|
|
169
|
+
}
|
|
170
|
+
/** The page colours in force now for `mode` (the instance the route is about), or null: the page's own. */
|
|
171
|
+
export function pageNow(mode, route = navStore.get().route) {
|
|
172
|
+
const theme = pageThemeFor(openOf(route));
|
|
173
|
+
const p = theme && pagePalette(theme, mode);
|
|
174
|
+
return p && pageReadable(p, mode) ? p : null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Draw page colours `p` (pageNow) on the shell `el`, or clear them (null): the palette's tokens and the few derived
|
|
178
|
+
* from it (borders, raised rows, the accent's tint and the colour on it), through the CSSOM; class th-page.
|
|
179
|
+
*/
|
|
180
|
+
export function applyPageTheme(el, p, mode) {
|
|
181
|
+
if (!el || !el.style) return;
|
|
182
|
+
for (const prop of [...Object.values(PAGE_PROPS), ...DERIVED_PROPS]) el.style.removeProperty(prop);
|
|
183
|
+
el.classList.remove("th-page");
|
|
184
|
+
if (!p) return;
|
|
185
|
+
for (const [k, prop] of Object.entries(PAGE_PROPS)) el.style.setProperty(prop, p[k]);
|
|
186
|
+
el.style.setProperty("--text-3", p.text2);
|
|
187
|
+
el.style.setProperty("--surface-2", mix(p.surface, p.text, 0.08));
|
|
188
|
+
el.style.setProperty("--border", mix(p.bg, p.text, 0.14));
|
|
189
|
+
el.style.setProperty("--border-strong", mix(p.bg, p.text, 0.45));
|
|
190
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(p.accent.slice(i, i + 2), 16));
|
|
191
|
+
el.style.setProperty("--accent-dim", `rgba(${r}, ${g}, ${b}, ${mode === "dark" ? 0.14 : 0.08})`);
|
|
192
|
+
el.style.setProperty("--on-accent", ratio("#ffffff", p.accent) >= ratio("#0d0d0d", p.accent) ? "#ffffff" : "#0d0d0d");
|
|
193
|
+
el.classList.add("th-page");
|
|
194
|
+
}
|
|
195
|
+
|
|
92
196
|
export function isThemeLocked(name) { return (themesStore.get().locked || []).includes(name); }
|
|
93
197
|
|
|
94
198
|
const TOKENS = ["th", "th-font-inter", "th-font-system", "th-font-mono", "th-bubble-none", "th-bubble-thin", "th-bubble-accent", "th-agent-bubble"];
|
|
@@ -193,7 +297,18 @@ export function themeMenuItems(name) {
|
|
|
193
297
|
const locked = isThemeLocked(name);
|
|
194
298
|
const w = themesStore.get().widgets;
|
|
195
299
|
const placed = w && own(w, name) && w[name] && typeof w[name] === "object" ? w[name] : {};
|
|
300
|
+
// #2196: where this agent's colours go — its own choice, the whole page, or only its chat (the person's wins).
|
|
301
|
+
const choice = pageChoice(name), theme = themeFor(name), mode = resolvedMode();
|
|
302
|
+
const agentsOwn = theme && theme.page && theme.page.on ? "chat.pageAgentOn" : "chat.pageAgentOff";
|
|
303
|
+
const readable = !!theme && pageReadable(pagePalette(theme, mode), mode);
|
|
304
|
+
const page = theme ? [
|
|
305
|
+
{ key: "page-heading", heading: true, label: t("chat.pageColours") },
|
|
306
|
+
{ key: "page-agent", checked: choice === null, label: t(agentsOwn), onSelect: () => setPageChoice(name, "agent") },
|
|
307
|
+
{ key: "page-page", checked: choice === "page", label: t("chat.pageWhole"), disabled: !readable && choice !== "page", onSelect: () => setPageChoice(name, "page") },
|
|
308
|
+
{ key: "page-chat", checked: choice === "chat", label: t("chat.pageChatOnly"), onSelect: () => setPageChoice(name, "chat") },
|
|
309
|
+
] : [];
|
|
196
310
|
return [
|
|
311
|
+
...page,
|
|
197
312
|
...["chat_header", "details"].filter((slot) => own(placed, slot)).map((slot) => ({
|
|
198
313
|
key: `widget-remove-${slot}`, label: t(slot === "chat_header" ? "chat.widgetRemoveHeader" : "chat.widgetRemoveDetails"), icon: "trash",
|
|
199
314
|
onSelect: () => removeWidget(name, slot),
|