@songsid/agend 2.2.0-beta.2 → 2.2.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 (75) hide show
  1. package/dist/auth-api.js +2 -0
  2. package/dist/auth-api.js.map +1 -1
  3. package/dist/channel/adapters/discord.d.ts +1 -1
  4. package/dist/channel/adapters/discord.js.map +1 -1
  5. package/dist/channel/adapters/telegram.d.ts +12 -2
  6. package/dist/channel/adapters/telegram.js +39 -8
  7. package/dist/channel/adapters/telegram.js.map +1 -1
  8. package/dist/channel/telegram-markdown.d.ts +6 -0
  9. package/dist/channel/telegram-markdown.js +104 -0
  10. package/dist/channel/telegram-markdown.js.map +1 -0
  11. package/dist/channel/tool-router.js +5 -4
  12. package/dist/channel/tool-router.js.map +1 -1
  13. package/dist/channel/types.d.ts +5 -3
  14. package/dist/config-validator.js +6 -5
  15. package/dist/config-validator.js.map +1 -1
  16. package/dist/daemon.d.ts +12 -0
  17. package/dist/daemon.js +93 -16
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/delivery-outbox.d.ts +11 -2
  20. package/dist/delivery-outbox.js +13 -2
  21. package/dist/delivery-outbox.js.map +1 -1
  22. package/dist/delivery-reconciliation.d.ts +18 -1
  23. package/dist/delivery-reconciliation.js +67 -2
  24. package/dist/delivery-reconciliation.js.map +1 -1
  25. package/dist/fleet-manager.d.ts +22 -1
  26. package/dist/fleet-manager.js +96 -15
  27. package/dist/fleet-manager.js.map +1 -1
  28. package/dist/locale.js +4 -0
  29. package/dist/locale.js.map +1 -1
  30. package/dist/outbound-handlers.d.ts +2 -0
  31. package/dist/outbound-handlers.js +21 -11
  32. package/dist/outbound-handlers.js.map +1 -1
  33. package/dist/outbound-schemas.d.ts +2 -2
  34. package/dist/outbound-schemas.js +1 -1
  35. package/dist/outbound-schemas.js.map +1 -1
  36. package/dist/public-web-gateway.js +4 -2
  37. package/dist/public-web-gateway.js.map +1 -1
  38. package/dist/settings-change.js +2 -2
  39. package/dist/tips.js +1 -1
  40. package/dist/tips.js.map +1 -1
  41. package/dist/transcript-delta-reader.d.ts +3 -3
  42. package/dist/transcript-delta-reader.js +8 -8
  43. package/dist/transcript-delta-reader.js.map +1 -1
  44. package/dist/ui/chat-store.js +1 -1
  45. package/dist/ui/chat-strings.js +12 -12
  46. package/dist/ui/chat-thread.js +22 -3
  47. package/dist/ui/panel-chat.js +3 -0
  48. package/dist/ui/panel-settings.js +15 -8
  49. package/dist/ui/settings-strings.js +6 -2
  50. package/dist/ui/shared/app-i18n.js +35 -13
  51. package/dist/ui/shared/app-prefs.js +249 -0
  52. package/dist/ui/shared/app-shell.js +17 -4
  53. package/dist/ui/shared/app-store.js +3 -1
  54. package/dist/ui/shared/app.css +30 -0
  55. package/dist/ui/shared/app.js +6 -1
  56. package/dist/ui/shared/header-tools.js +55 -5
  57. package/dist/ui/shared/instance-nav.js +99 -31
  58. package/dist/ui/shared/panel-view.js +6 -0
  59. package/dist/ui/shared/theme.js +39 -7
  60. package/dist/ui/shared/ui-icons.js +1 -0
  61. package/dist/ui/shared/usage-dialog.js +13 -4
  62. package/dist/ui/shared/view-strings.js +20 -2
  63. package/dist/view-api.js +5 -0
  64. package/dist/view-api.js.map +1 -1
  65. package/dist/web-api.d.ts +10 -1
  66. package/dist/web-api.js +103 -1
  67. package/dist/web-api.js.map +1 -1
  68. package/dist/web-auth.js +2 -1
  69. package/dist/web-auth.js.map +1 -1
  70. package/dist/web-prefs.d.ts +63 -0
  71. package/dist/web-prefs.js +263 -0
  72. package/dist/web-prefs.js.map +1 -0
  73. package/dist/web-session.js +1 -1
  74. package/dist/web-session.js.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,249 @@
1
+ // #1665: the web preferences that follow the person between devices — kept on the host (/ui/prefs), with this browser's
2
+ // localStorage, under the keys every panel already reads, as the cache. Design: the comment on #1665.
3
+ //
4
+ // - Synced: theme, language, the sidebar (order, folds, filter, favorites — #1664) and the HTML-preview opt-in. The tour and the first-run
5
+ // card stay on each device, as every layout choice does.
6
+ // - The signed-in page is served with the host's values (<html data-prefs>). This module, imported before any panel
7
+ // reads its key, copies them into the cache: the host wins once it has a value — and, on a device already in step
8
+ // with it, a key the host does not have is the default (its stale copy here is cleared). theme.js and the
9
+ // dictionary did the same for the theme and language, before paint.
10
+ // - A change made here is cached at once and sent to the host (a short debounce gathers a burst, such as typing in the
11
+ // filter). Writes go one at a time; a key stays this page's own from the change until its write has ended, so no
12
+ // answer — a read or another write — that started before can put an older value back. Every answer carries the
13
+ // host's revision, and one older than what this page already has is ignored.
14
+ // - The public link, and the anonymous View reader, have no host write: theirs stays local.
15
+ // - The first load on a device (no agend_prefs_synced yet) uploads what it had locally for the keys the host does not
16
+ // have yet, each with onlyIfUnset — the first device to arrive wins — then reads the host's values back. Only once
17
+ // every upload got an answer and that read succeeded is the device marked: anything else tries again next load.
18
+ // - The status frame's prefs_rev: another device changed something; the host's values are read again and applied. A
19
+ // revision counts as seen only once its read succeeded (a failed one is tried again, at most every few seconds).
20
+ import { deviceLanguage, lang, setLang } from "./app-i18n.js";
21
+ import { appStore } from "./app-store.js";
22
+
23
+ const SYNCED_FLAG = "agend_prefs_synced";
24
+ const RETRY_MS = 5000;
25
+ const json = { toLocal: (v) => JSON.stringify(v), fromLocal: (s) => { try { return s == null ? undefined : JSON.parse(s); } catch { return undefined; } } };
26
+ /** Each synced preference: its cache key, and how a host value is cached (null: no entry) and read back. */
27
+ export const SYNCED = {
28
+ theme: { ls: "agend_theme", toLocal: (v) => (v === "light" || v === "dark" ? v : null), fromLocal: (s) => (s === "light" || s === "dark" ? s : undefined) },
29
+ lang: { ls: "agend_lang", toLocal: (v) => (v === "en" || v === "zh-TW" ? v : null), fromLocal: (s) => (s === "en" || s === "zh-TW" ? s : undefined) },
30
+ "nav.order": { ls: "agend_view_sidebar_order", ...json },
31
+ "nav.collapsed": { ls: "agend_instance_collapsed", ...json },
32
+ "nav.filter": { ls: "agend_instance_filter", ...json },
33
+ "nav.favorites": { ls: "agend_favorites", ...json }, // #1664
34
+ "preview.optIn": { ls: "agend_html_preview", toLocal: (v) => (v ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
35
+ };
36
+ const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
37
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
38
+
39
+ function readLocal(key) { try { return localStorage.getItem(key); } catch { return null; } }
40
+ function writeLocal(key, s) { try { if (s == null) localStorage.removeItem(key); else localStorage.setItem(key, s); } catch { /* this page only */ } }
41
+ function cache(key, value) { const k = SYNCED[key]; writeLocal(k.ls, value == null ? null : k.toLocal(value)); }
42
+ const inStep = () => readLocal(SYNCED_FLAG) === "1";
43
+
44
+ function pageHost() {
45
+ try {
46
+ const d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null");
47
+ return d && typeof d === "object" && typeof d.rev === "number" && d.values && typeof d.values === "object" ? d : null;
48
+ } catch { return null; }
49
+ }
50
+
51
+ const initial = typeof document !== "undefined" ? pageHost() : null;
52
+ let rev = initial ? initial.rev : null;
53
+ let host = initial ? initial.values : null;
54
+ const pending = new Map(); // key → value chosen here, not sent yet
55
+ const mine = new Map(); // key → value chosen here whose write is not over (pending or out)
56
+ const inflight = new Map(); // key → writes sent and not answered yet
57
+ let flushTimer = null;
58
+ let writes = Promise.resolve(); // one write at a time, in the order they were made
59
+ const listeners = new Set();
60
+
61
+ // The host's values into the cache, now — before the sidebar, the tour or the first-run card read their keys. A key
62
+ // the host does not have: the default, on a device in step with the host; this device's own (to upload) otherwise.
63
+ if (host) {
64
+ const step = inStep();
65
+ for (const key of Object.keys(SYNCED)) {
66
+ if (own(host, key)) cache(key, host[key]);
67
+ else if (step) cache(key, null);
68
+ }
69
+ }
70
+
71
+ /** This page may change the host's preferences: the signed-in app, not over the public link. */
72
+ export function prefsWritable() {
73
+ return !!initial && !(typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1");
74
+ }
75
+ /**
76
+ * The value in force for a key, in memory — never only in this browser's storage (a full storage must not bring back
77
+ * an old value): this page's own choice while its write is not over; else the host's; else, on a device in step with
78
+ * the host, the default ({ value: undefined }). null: not known here (no host values on this page, or a device not yet
79
+ * in step) — the caller reads this browser's copy.
80
+ */
81
+ export function prefInForce(key) {
82
+ if (!own(SYNCED, key)) return null;
83
+ if (mine.has(key)) return { value: mine.get(key) };
84
+ if (host && own(host, key)) return { value: host[key] };
85
+ if (initial && inStep()) return { value: undefined };
86
+ return null;
87
+ }
88
+ /** The host's value for a key as this page last saw it (undefined: not set there, or not known). */
89
+ export function hostPref(key) { return host && own(host, key) ? host[key] : undefined; }
90
+ /** Called with the keys another device changed, after they are cached and applied here. Returns the unsubscribe. */
91
+ export function onPrefsChanged(fn) { listeners.add(fn); return () => listeners.delete(fn); }
92
+
93
+ /** A host answer ({ values, rev }): a real view of the host (not an unreadable store's defaults)? */
94
+ const isView = (body) => !!body && typeof body.rev === "number" && !!body.values && typeof body.values === "object" && !body.unreadable;
95
+ /** Take a view as this page's — unless it is older than the one it has. True: taken. */
96
+ function take(body) {
97
+ if (!isView(body) || (rev !== null && body.rev < rev)) return false;
98
+ rev = body.rev;
99
+ host = body.values;
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * A preference chosen on this page: cached now, and kept on the host (a burst within 400 ms is one write). Not writable
105
+ * (the public link, the anonymous reader): this browser only, as before.
106
+ */
107
+ export function savePref(key, value) {
108
+ if (!own(SYNCED, key)) return;
109
+ cache(key, value);
110
+ if (!prefsWritable()) return;
111
+ pending.set(key, value);
112
+ mine.set(key, value);
113
+ clearTimeout(flushTimer);
114
+ flushTimer = setTimeout(flush, 400);
115
+ }
116
+
117
+ /** One write. ok: the host took it (its answer is a view; an older one than this page has is not taken). */
118
+ async function post(set, onlyIfUnset) {
119
+ const res = await fetch("/ui/prefs", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify(onlyIfUnset ? { set, onlyIfUnset: true } : { set }) });
121
+ const body = await res.json().catch(() => null);
122
+ const ok = res.ok && isView(body);
123
+ if (ok) take(body);
124
+ return { ok, status: res.status };
125
+ }
126
+
127
+ function flush() {
128
+ flushTimer = null;
129
+ if (!pending.size) return writes;
130
+ const set = Object.fromEntries(pending);
131
+ const keys = [...pending.keys()];
132
+ pending.clear();
133
+ for (const k of keys) inflight.set(k, (inflight.get(k) || 0) + 1);
134
+ writes = writes.then(async () => {
135
+ let ok = false;
136
+ try { ok = (await post(set, false)).ok; } catch { ok = false; }
137
+ const released = new Set();
138
+ for (const k of keys) {
139
+ const n = (inflight.get(k) || 1) - 1;
140
+ if (n) inflight.set(k, n); else inflight.delete(k);
141
+ if (!n && !pending.has(k)) { mine.delete(k); released.add(k); }
142
+ }
143
+ // Each key whose last write this was, and not changed here again since, now shows the newest host view this page
144
+ // has — usually its own value; another device's newer one if that arrived while the write was out (held back then,
145
+ // and an older answer to this write cannot overrule it); refused or not sent, what the host has (read back first).
146
+ // Always applied: what the page shows came from this page's own choice, not from the cache, so the cache matching
147
+ // the host (a storage that refused the choice) says nothing about the screen.
148
+ if (!ok) await refresh();
149
+ align(released, { force: true });
150
+ });
151
+ return writes;
152
+ }
153
+
154
+ /**
155
+ * Read the host's values again and apply the ones that differ from this page's — not a key still being changed here
156
+ * (unsent, or its write not answered yet), and nothing from an answer older than the view this page has. True: the read
157
+ * succeeded (taken, or older than what this page already has — nothing to do).
158
+ */
159
+ export async function refresh() {
160
+ if (!initial) return false;
161
+ let body = null;
162
+ try {
163
+ const res = await fetch("/ui/prefs", { credentials: "same-origin" });
164
+ body = res.ok ? await res.json() : null;
165
+ } catch { body = null; }
166
+ if (!isView(body)) return false;
167
+ const before = new Map(Object.keys(SYNCED).map((key) => [key, own(host, key) ? SYNCED[key].toLocal(host[key]) : null]));
168
+ if (take(body)) align(Object.keys(SYNCED), { before });
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Show the host view this page has for these keys — except a key still being changed here (unsent, or out). A key is
174
+ * applied when its host value moved in memory (`before`: the host view before this read), when this browser's copy
175
+ * differs, or always (`force`: this page's own write just ended). The copy alone never decides: a storage that refused
176
+ * a write keeps an old copy that may equal the host while the screen shows something else.
177
+ */
178
+ function align(keys, opts = {}) {
179
+ const changed = new Set();
180
+ for (const key of keys) {
181
+ if (pending.has(key) || inflight.has(key)) continue;
182
+ const k = SYNCED[key];
183
+ const want = own(host, key) ? k.toLocal(host[key]) : null;
184
+ const copyDiffers = readLocal(k.ls) !== want;
185
+ const moved = !!opts.before && opts.before.get(key) !== want;
186
+ if (!copyDiffers && !moved && !opts.force) continue;
187
+ if (copyDiffers) writeLocal(k.ls, want);
188
+ changed.add(key);
189
+ }
190
+ if (changed.size) apply(changed);
191
+ }
192
+
193
+ function apply(changed) {
194
+ // Only what differs from what the page shows (a key re-applied after its own write may already be in force).
195
+ const T = globalThis.AgendTheme;
196
+ if (changed.has("theme") && typeof T !== "undefined") { const v = hostPref("theme") || "system"; if (typeof T.get !== "function" || T.get() !== v) T.set(v); }
197
+ if (changed.has("lang")) {
198
+ const l = hostPref("lang");
199
+ // None chosen any more: the device's own language, on this page only (no copy is kept for it).
200
+ if (l) { if (l !== lang()) setLang(l); } else if (deviceLanguage() !== lang()) setLang(deviceLanguage(), { store: false });
201
+ }
202
+ const P = globalThis.AgendPreview;
203
+ if (changed.has("preview.optIn") && P && typeof P.setOptIn === "function") {
204
+ const on = hostPref("preview.optIn") === true;
205
+ if (typeof P.optedIn !== "function" || P.optedIn() !== on) P.setOptIn(on);
206
+ }
207
+ for (const fn of [...listeners]) { try { fn(changed); } catch { /* one listener's error stops no other */ } }
208
+ }
209
+
210
+ /**
211
+ * Once per device: upload what this browser had for the keys the host has not got yet (each on its own, onlyIfUnset — a
212
+ * value the host refuses as invalid is simply not taken), then adopt the host's values. Marked only when every upload
213
+ * was answered (taken, or refused as invalid) and the read back succeeded; a request with no answer, a server error, a
214
+ * refused session or an unreadable store leaves the device unmarked, so the next load tries again.
215
+ */
216
+ export async function migratePrefs() {
217
+ if (!prefsWritable() || inStep()) return false;
218
+ for (const key of Object.keys(SYNCED)) {
219
+ if (own(host, key)) continue;
220
+ const local = SYNCED[key].fromLocal(readLocal(SYNCED[key].ls));
221
+ if (local === undefined) continue;
222
+ let r;
223
+ try { r = await post({ [key]: local }, true); } catch { return false; }
224
+ if (!r.ok && r.status !== 400) return false;
225
+ }
226
+ if (!(await refresh())) return false;
227
+ writeLocal(SYNCED_FLAG, "1");
228
+ return true;
229
+ }
230
+
231
+ /** The app's boot ("full" mode): the first-load migration, then another device's changes at each status frame. */
232
+ export function bootPrefs() {
233
+ if (!initial) return;
234
+ // Each new revision the frames announce, read once (the store changes many times a second); one announced while a read
235
+ // is out is read as soon as it ends. Seen only once its read succeeded: after a failed read the next try waits for a
236
+ // later frame, at least RETRY_MS on.
237
+ let seen = rev, busy = false, failedAt = -Infinity;
238
+ const check = () => {
239
+ const r = appStore.get().prefsRev;
240
+ if (typeof r !== "number" || r === seen || busy) return;
241
+ if (r === rev) { seen = r; return; }
242
+ if (now() - failedAt < RETRY_MS) return;
243
+ busy = true;
244
+ refresh().then((ok) => { if (ok) seen = r; else failedAt = now(); }, () => { failedAt = now(); })
245
+ .finally(() => { busy = false; check(); });
246
+ };
247
+ appStore.subscribe(check);
248
+ migratePrefs();
249
+ }
@@ -8,10 +8,12 @@ import { ConfirmHost } from "./ui-confirm.js";
8
8
  import { t, lang, setLang, onLang } from "./app-i18n.js";
9
9
  import { appStore, createStore, useStore } from "./app-store.js";
10
10
  import { navStore } from "./app-nav.js";
11
- import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath } from "./app-route.js";
11
+ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath, fleetPath } from "./app-route.js";
12
12
  import { SessionMenu } from "./app-session.js";
13
+ import { savePref } from "./app-prefs.js";
13
14
  import { ErrorState, Skeleton } from "./ui-states.js";
14
15
  import { InstanceNav, RosterNav } from "./instance-nav.js";
16
+ import { UsageRail } from "./header-tools.js";
15
17
 
16
18
  /** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
17
19
  export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
@@ -104,7 +106,8 @@ export function signInHref() {
104
106
  function Sidebar({ route, onNewInstance, viewOnly }) {
105
107
  const app = useStore(appStore);
106
108
  const shell = useStore(shellStore);
107
- const on = (panel) => !!route && (route.panel === panel || (panel === "fleet" && route.panel === "details"));
109
+ // #1675: the Fleet panel is "Fleet overview"'s (one current row); the "Fleet" row is current on an instance's Details.
110
+ const on = (panel) => !!route && (panel === "fleet" ? route.panel === "details" : route.panel === panel);
108
111
  const openInstance = openInstanceOf(route);
109
112
  // A count badge is hidden at 0; its number is also in the link's accessible name.
110
113
  const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
@@ -126,6 +129,11 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
126
129
  </div>
127
130
  </aside>`;
128
131
  }
132
+ // #1675: the whole fleet, on its Org chart (#1389), from anywhere — top left, so at the top of a phone's drawer too. The
133
+ // "Fleet" row under it goes to the open instance's Details (#1523 N2); this one is the Fleet panel, any of its tabs.
134
+ const onFleet = !!route && route.panel === "fleet";
135
+ const overview = html`<a class=${`side-row side-overview${onFleet ? " active" : ""}`} href=${fleetPath("org")}
136
+ aria-current=${onFleet ? "page" : undefined} onClick=${closeDrawer}><${Icon} name="org" /><span class="grow">${t("app.fleetOverview")}</span></a>`;
129
137
  return html`<aside id="sidebar" class="sidebar" aria-label=${t("app.menu")}>
130
138
  <div class="side-head">
131
139
  <a class="brand" href="/ui" onClick=${closeDrawer}>${t("app.brand")}</a>
@@ -133,6 +141,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
133
141
  <button type="button" class="icon-btn" onClick=${onNewInstance} aria-label=${t("app.newInstance")} title=${t("app.newInstance")}><${Icon} name="plus" /></button>
134
142
  </div>
135
143
  <nav class="side-nav" aria-label=${t("app.menu")}>
144
+ ${overview}
136
145
  ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
137
146
  ${navLink("fleet", openInstance ? detailsPath(openInstance) : "/ui/fleet", "fleet", t("app.fleet"))}
138
147
  ${navLink("view", "/view", "view", t("app.view"))}
@@ -158,12 +167,15 @@ function ViewOnlyNav({ route }) {
158
167
  export function DevicePrefs({ labels = false } = {}) {
159
168
  const [theme, setTheme] = useState(typeof AgendTheme !== "undefined" ? AgendTheme.get() : "system");
160
169
  const [l, setL] = useState(lang());
170
+ // #1665: what is in force, as it changes — another device's choice arriving while this is open is shown at once.
171
+ useEffect(() => (typeof AgendTheme !== "undefined" && typeof AgendTheme.onChange === "function" ? AgendTheme.onChange(setTheme) : undefined), []);
172
+ useEffect(() => onLang(setL), []);
161
173
  return html`<div class=${`prefs${labels ? " labelled" : ""}`}>
162
174
  <label class="pref"><${Icon} name="sun" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.theme")}</span>
163
- <select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); }}>
175
+ <select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); savePref("theme", e.target.value); }}>
164
176
  <option value="system">${t("app.themeSystem")}</option><option value="light">${t("app.themeLight")}</option><option value="dark">${t("app.themeDark")}</option></select></label>
165
177
  <label class="pref"><${Icon} name="globe" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.language")}</span>
166
- <select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); }}>
178
+ <select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); savePref("lang", e.target.value); }}>
167
179
  <option value="en">English</option><option value="zh-TW">中文</option></select></label>
168
180
  </div>`;
169
181
  }
@@ -351,6 +363,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
351
363
  ${viewOnly ? null : html`<${OperationLine} route=${nav.route} />`}
352
364
  <${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
353
365
  </main>
366
+ <${UsageRail} route=${nav.route} />
354
367
  <${BottomTabs} route=${nav.route} viewOnly=${viewOnly} />
355
368
  ${shell.dialog ? html`<${shell.dialog.Component} ...${shell.dialog.props} onClose=${closeDialog} />` : null}
356
369
  ${viewOnly || (nav.route && nav.route.panel === "settings") ? null : html`<${PendingChanges} />`}
@@ -36,7 +36,9 @@ export function applyStatus(d) {
36
36
  if (i.state === "awaiting_input") awaiting[i.name] = typeof i.interaction_summary === "string" ? i.interaction_summary : "";
37
37
  }
38
38
  appStore.set({ ready: true, instances: d.instances, uptime: d.uptime ?? 0, exec, awaiting,
39
- connections: typeof d.connections === "number" ? d.connections : null });
39
+ connections: typeof d.connections === "number" ? d.connections : null,
40
+ // #1665: the host preferences' revision (app-prefs.js re-reads them when it moves).
41
+ prefsRev: typeof d.prefs_rev === "number" ? d.prefs_rev : null });
40
42
  }
41
43
 
42
44
  /** One "activity" event: an instance started or stopped working. */
@@ -320,6 +320,12 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
320
320
  .msg.user .meta { justify-content: flex-end; }
321
321
  .msg.user .body { background: var(--user-bubble); border-radius: var(--r-xl); padding: 10px var(--s-4); }
322
322
  .msg.agent { align-self: stretch; }
323
+ /* #1672: a status line (another instance's message, a daemon notice): muted plain text, no bubble; a long one folds. */
324
+ .msg.status { align-self: stretch; font-size: var(--fs-sm); color: var(--text-3); }
325
+ .msg.status .st-text { white-space: pre-wrap; border-left: 2px solid var(--border); padding-left: var(--s-3); }
326
+ .msg.status.folded .st-text { max-height: 9em; overflow: hidden; mask-image: linear-gradient(to bottom, #000 60%, transparent); -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent); }
327
+ .msg.status .st-more { border: 0; background: none; color: var(--text-2); cursor: pointer; font-size: var(--fs-xs); padding: var(--s-1) 0 0 var(--s-3); }
328
+ .msg.status .st-more:hover { color: var(--text); }
323
329
  .msg-tools { display: flex; gap: var(--s-1); margin-top: var(--s-1); opacity: 0; transition: opacity var(--t-fast); }
324
330
  .msg:hover .msg-tools, .msg:focus-within .msg-tools { opacity: 1; }
325
331
  @media (hover: none) { .msg-tools { opacity: 1; } }
@@ -589,6 +595,24 @@ html.lb-open .scroller { overflow: hidden; }
589
595
  .caret.folded { transform: rotate(-90deg); }
590
596
  .caret.flipped { transform: rotate(180deg); } /* the card's toggle: up when closed (it opens upwards) */
591
597
  .v-inst { align-items: center; }
598
+ /* #1664: favorites — the star after a row (on hover or focus on a desktop, always when starred; on a phone in Edit) */
599
+ .inst-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
600
+ .inst-head .side-label { flex: 1; min-width: 0; }
601
+ .fav-edit { margin-right: var(--s-2); display: inline-flex; align-items: center; gap: var(--s-1); }
602
+ .v-li { display: flex; align-items: center; gap: 2px; }
603
+ .v-li > .inst { flex: 1; min-width: 0; }
604
+ .inst-star, .inst-move { flex: none; width: 28px; height: 28px; padding: 0; color: var(--text-3); }
605
+ .inst-star.on { color: var(--warn); }
606
+ .inst-star.on svg, .fav-mark { fill: currentColor; }
607
+ .fav-head { cursor: default; }
608
+ .fav-mark { color: var(--warn); }
609
+ .inst-nav:not(.fav-editing) .inst-star:not(.on) { visibility: hidden; }
610
+ /* As specific as the rule that hides it (and later), so hover and focus win. */
611
+ @media (hover: hover) { .inst-nav:not(.fav-editing) .v-li:hover .inst-star, .inst-nav:not(.fav-editing) .v-li:focus-within .inst-star { visibility: visible; } }
612
+ @media (hover: none) { .inst-nav:not(.fav-editing) .inst-star { display: none; } }
613
+ /* Edit: room for ↑ ↓ ☆ — the context and CLI marks step aside so the name keeps its width. */
614
+ .fav-editing .v-li .v-meta, .fav-editing .v-li .cli-icon { display: none; }
615
+ @media (max-width: 899px) { .fav-editing .inst-star, .fav-editing .inst-move { width: 36px; height: 36px; } }
592
616
  .v-inst .dot { margin-top: 0; }
593
617
  .v-meta { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
594
618
  .cli-icon { width: 10px; height: 10px; flex: 0 0 10px; border-radius: 50%; border: 1px solid var(--border-strong); }
@@ -660,6 +684,12 @@ html.lb-open .scroller { overflow: hidden; }
660
684
  .u-sub { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
661
685
  .u-err { color: var(--warn); font-size: var(--fs-sm); }
662
686
  .u-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
687
+ /* #1673: the usage rail beside the page on a wide screen (header-tools.js UsageRail; shown only at min-width 1200px). */
688
+ .u-rail { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--border); background: var(--bg); }
689
+ .u-rail-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); padding: var(--s-3) var(--s-3) var(--s-2) var(--s-4); }
690
+ .u-rail-head h2 { margin: 0; font-size: var(--fs-md); font-weight: var(--fw-semibold); }
691
+ .u-rail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--s-3) var(--s-4); display: flex; flex-direction: column; gap: var(--s-3); }
692
+ @media (max-width: 1199px) { .u-rail { display: none; } }
663
693
  @media (max-width: 899px) {
664
694
  .hide-narrow { display: none; }
665
695
  .v-card { padding-left: var(--s-3); padding-right: var(--s-3); }
@@ -5,6 +5,8 @@
5
5
  // session, and so is View (panel-view.js, loaded on demand). The panels that need a session live under /ui/js/ and are
6
6
  // reached only by the dynamic imports below, in "full" mode. A test walks the static imports over real HTTP and asserts
7
7
  // none of them names /ui/.
8
+ // #1665 first: the host's preferences go into this browser's cache before any module below reads its key.
9
+ import { bootPrefs } from "./app-prefs.js";
8
10
  import { html, render } from "./app-html.js";
9
11
  import { Shell, handleKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
10
12
  import { installSwipe } from "./app-swipe.js";
@@ -15,11 +17,12 @@ import { chatPath } from "./app-route.js";
15
17
  import { startNeedsNotifier } from "./app-needs.js";
16
18
  import { toast } from "./ui-toast.js";
17
19
  import { t } from "./app-i18n.js";
18
- import { initTextSize } from "./header-tools.js";
20
+ import { initTextSize, initUsageRail } from "./header-tools.js";
19
21
 
20
22
  const boot = document.body.dataset;
21
23
  // #1523 N3: this device's text size, on the root before the first paint of any panel.
22
24
  initTextSize();
25
+ initUsageRail();
23
26
  // "full": a signed-in page. "view-only": an anonymous reader of /view under web.view_access: open — no stream, no
24
27
  // session-only panel, nothing under /ui/ is ever imported or read (#1408 §3).
25
28
  const mode = boot.mode === "view-only" ? "view-only" : "full";
@@ -79,6 +82,8 @@ if (mode === "full") {
79
82
  panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
80
83
  // #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
81
84
  startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
85
+ // #1665: this device's first upload of its own choices (once), then another device's changes at each status frame.
86
+ bootPrefs();
82
87
  onNewInstance = async () => {
83
88
  closeDrawer(); // on a phone the dialog opens from the drawer: the drawer goes first
84
89
  let m;
@@ -1,13 +1,15 @@
1
1
  // #1523 N3 (§3.4, Q2 = A): the controls every page's header has, in one order on Chat, View, Details and Fleet —
2
2
  // Aa text size, shared per device: S / M / L (View also keeps Fit, its own extra choice);
3
3
  // ◔ AI usage, unless the fleet turned it off (web.usage_panel: false — said by the page itself, no probe read).
4
+ // #1673: on a wide screen it is a right rail beside the page (shown or hidden per device); ◔ toggles it. On a
5
+ // narrower one, ◔ opens the dialog.
4
6
  // The size is one attribute on the root (data-text-size); CSS turns it into one custom property the chat, Details and
5
7
  // Fleet read (#1300: no style attribute). View's terminal multiplies its fitted size by VIEW_SCALE, as its density did.
6
- import { html, useState } from "./app-html.js";
8
+ import { html, useEffect, useState } from "./app-html.js";
7
9
  import { t } from "./app-i18n.js";
8
10
  import { createStore, useStore } from "./app-store.js";
9
11
  import { Icon } from "./ui-icons.js";
10
- import { UsageDialog } from "./usage-dialog.js";
12
+ import { UsageBody, UsageDialog } from "./usage-dialog.js";
11
13
 
12
14
  export const TEXT_SIZE_KEY = "agend_text_size";
13
15
  export const VIEW_FIT_KEY = "agend_view_fit";
@@ -73,16 +75,64 @@ export function usageOffered() {
73
75
  try { return document.body && document.body.dataset ? document.body.dataset.usagePanel !== "0" : true; } catch { return true; }
74
76
  }
75
77
 
78
+ // #1673: the rail. Wide enough for it beside the sidebar and the page: RAIL_MEDIA (followed as it changes). Shown or
79
+ // hidden is this device's choice (agend_usage_rail; "0" = hidden, else shown) — a layout choice, not synced (#1665).
80
+ export const RAIL_MEDIA = "(min-width: 1200px)";
81
+ export const RAIL_KEY = "agend_usage_rail";
82
+ /** The pages whose header has ◔ — the rail is theirs. */
83
+ export const RAIL_PANELS = ["chat", "view", "details", "fleet"];
84
+ export const usageRailStore = createStore({ open: true });
85
+ /** A page load: this device's choice. */
86
+ export function initUsageRail() { usageRailStore.set({ open: read(RAIL_KEY) !== "0" }); }
87
+ export function setUsageRail(open) { write(RAIL_KEY, open ? "1" : "0"); usageRailStore.set({ open: !!open }); }
88
+ const railQuery = () => { try { return typeof matchMedia === "function" ? matchMedia(RAIL_MEDIA) : null; } catch { return null; } };
89
+ /** Whether the screen is wide enough for the rail, now and as it changes. */
90
+ export function useRailWidth() {
91
+ const [wide, setWide] = useState(() => !!railQuery()?.matches);
92
+ useEffect(() => {
93
+ const q = railQuery();
94
+ if (!q) return undefined;
95
+ const on = () => setWide(!!q.matches);
96
+ on();
97
+ if (typeof q.addEventListener === "function") { q.addEventListener("change", on); return () => q.removeEventListener("change", on); }
98
+ if (typeof q.addListener === "function") { q.addListener(on); return () => q.removeListener(on); }
99
+ return undefined;
100
+ }, []);
101
+ return wide;
102
+ }
103
+
104
+ /**
105
+ * The rail: beside the page on a wide screen, on the pages with ◔, while this device shows it. Its reads are its own
106
+ * lease: they go on across navigation and end when it is hidden, the screen narrows, or it goes.
107
+ */
108
+ export function UsageRail({ route }) {
109
+ const wide = useRailWidth();
110
+ const { open } = useStore(usageRailStore);
111
+ if (!wide || !open || !route || !RAIL_PANELS.includes(route.panel) || !usageOffered()) return null;
112
+ return html`<aside class="u-rail" aria-label=${t("view.usage.title_plain")}>
113
+ <div class="u-rail-head"><h2>${t("view.usage.title")}</h2>
114
+ <button type="button" class="icon-btn" onClick=${() => setUsageRail(false)} aria-label=${t("app.usageHide")} title=${t("app.usageHide")}><${Icon} name="close" size=${16} /></button></div>
115
+ <div class="u-rail-body"><${UsageBody} owner="usage-rail" /></div>
116
+ </aside>`;
117
+ }
118
+
76
119
  /**
77
- * ◔: the usage dialog (usage-dialog.js). It belongs to the navigation that opened it (`navKey`, #1408 §4; #1563
120
+ * ◔: on a wide screen, shows or hides the rail (#1673); otherwise the usage dialog (usage-dialog.js). It belongs to the navigation that opened it (`navKey`, #1408 §4; #1563
78
121
  * review): another route, the same route again, another tab of the panel or a language switch closes it, and its
79
122
  * reads and timer end with it — the dialog's lease is that navigation's.
80
123
  */
81
124
  export function UsageButton({ navKey }) {
82
125
  const [openFor, setOpenFor] = useState(null);
126
+ const wide = useRailWidth();
127
+ const rail = useStore(usageRailStore);
128
+ // Widening ends a dialog opened on the narrower screen — closed for good, not hidden while wide: narrowing again
129
+ // (after the rail was hidden, or with this device's rail hidden) never brings it back (#1679 review).
130
+ useEffect(() => { if (wide) setOpenFor(null); }, [wide]);
83
131
  if (!usageOffered()) return null;
84
- const open = openFor !== null && openFor === navKey;
85
- return html`<button type="button" class="btn btn-ghost btn-sm hd-usage" onClick=${() => setOpenFor(navKey)} aria-label=${t("app.usage")} title=${t("app.usage")}>
132
+ const open = !wide && openFor !== null && openFor === navKey;
133
+ const click = () => (wide ? setUsageRail(!rail.open) : setOpenFor(navKey));
134
+ return html`<button type="button" class="btn btn-ghost btn-sm hd-usage" onClick=${click} aria-label=${t("app.usage")} title=${t("app.usage")}
135
+ aria-pressed=${wide ? (rail.open ? "true" : "false") : undefined}>
86
136
  <${Icon} name="chart" size=${16} /><span class="hide-narrow" aria-hidden="true">${t("app.usage")}</span></button>
87
137
  ${open ? html`<${UsageDialog} key=${navKey} owner=${navKey} onClose=${() => setOpenFor(null)} />` : null}`;
88
138
  }