@songsid/agend 2.2.0-beta.1 → 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 (197) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +10 -7
  4. package/dist/agent-endpoint.js +21 -9
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.d.ts +47 -0
  7. package/dist/agent-whats-new.js +141 -0
  8. package/dist/agent-whats-new.js.map +1 -0
  9. package/dist/auth-api.d.ts +2 -2
  10. package/dist/auth-api.js +4 -2
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/backend/kiro-engine-ledger.d.ts +5 -0
  13. package/dist/backend/kiro-engine-ledger.js +20 -0
  14. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  15. package/dist/backend/kiro-identity.d.ts +6 -0
  16. package/dist/backend/kiro-identity.js +19 -5
  17. package/dist/backend/kiro-identity.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +14 -2
  19. package/dist/backend/kiro.js +84 -9
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/types.d.ts +4 -2
  22. package/dist/backend/types.js.map +1 -1
  23. package/dist/channel/adapters/discord.d.ts +11 -1
  24. package/dist/channel/adapters/discord.js +23 -1
  25. package/dist/channel/adapters/discord.js.map +1 -1
  26. package/dist/channel/adapters/telegram.d.ts +19 -1
  27. package/dist/channel/adapters/telegram.js +60 -9
  28. package/dist/channel/adapters/telegram.js.map +1 -1
  29. package/dist/channel/mcp-server.js +9 -1
  30. package/dist/channel/mcp-server.js.map +1 -1
  31. package/dist/channel/mcp-tools.d.ts +11 -0
  32. package/dist/channel/mcp-tools.js +23 -12
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/channel/telegram-markdown.d.ts +6 -0
  35. package/dist/channel/telegram-markdown.js +104 -0
  36. package/dist/channel/telegram-markdown.js.map +1 -0
  37. package/dist/channel/tool-router.js +9 -3
  38. package/dist/channel/tool-router.js.map +1 -1
  39. package/dist/channel/types.d.ts +14 -2
  40. package/dist/cli.js +55 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/command-table.js +1 -1
  43. package/dist/command-table.js.map +1 -1
  44. package/dist/config-validator.js +49 -0
  45. package/dist/config-validator.js.map +1 -1
  46. package/dist/cross-instance-envelope.d.ts +15 -2
  47. package/dist/cross-instance-envelope.js +27 -5
  48. package/dist/cross-instance-envelope.js.map +1 -1
  49. package/dist/daemon.d.ts +48 -0
  50. package/dist/daemon.js +250 -22
  51. package/dist/daemon.js.map +1 -1
  52. package/dist/delivery-outbox.d.ts +17 -2
  53. package/dist/delivery-outbox.js +13 -2
  54. package/dist/delivery-outbox.js.map +1 -1
  55. package/dist/delivery-reconciliation.d.ts +18 -1
  56. package/dist/delivery-reconciliation.js +67 -2
  57. package/dist/delivery-reconciliation.js.map +1 -1
  58. package/dist/fleet-manager.d.ts +63 -1
  59. package/dist/fleet-manager.js +314 -20
  60. package/dist/fleet-manager.js.map +1 -1
  61. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  62. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
  63. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
  64. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  65. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  66. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  67. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  68. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  69. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  70. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  71. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  72. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  73. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  74. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  75. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
  76. package/dist/general-knowledge/steering/core-rules.md +8 -7
  77. package/dist/instance-avatar.d.ts +36 -0
  78. package/dist/instance-avatar.js +57 -0
  79. package/dist/instance-avatar.js.map +1 -0
  80. package/dist/instance-config-impact.js +5 -0
  81. package/dist/instance-config-impact.js.map +1 -1
  82. package/dist/instance-lifecycle.d.ts +4 -0
  83. package/dist/instance-lifecycle.js +3 -1
  84. package/dist/instance-lifecycle.js.map +1 -1
  85. package/dist/instructions.d.ts +2 -0
  86. package/dist/instructions.js +10 -7
  87. package/dist/instructions.js.map +1 -1
  88. package/dist/locale.js +36 -26
  89. package/dist/locale.js.map +1 -1
  90. package/dist/next-steps.js +1 -1
  91. package/dist/next-steps.js.map +1 -1
  92. package/dist/outbound-handlers.d.ts +2 -0
  93. package/dist/outbound-handlers.js +28 -15
  94. package/dist/outbound-handlers.js.map +1 -1
  95. package/dist/outbound-schemas.d.ts +10 -2
  96. package/dist/outbound-schemas.js +15 -5
  97. package/dist/outbound-schemas.js.map +1 -1
  98. package/dist/public-web-gateway.js +7 -2
  99. package/dist/public-web-gateway.js.map +1 -1
  100. package/dist/service-activation.js +4 -3
  101. package/dist/service-activation.js.map +1 -1
  102. package/dist/settings-api.js +71 -21
  103. package/dist/settings-api.js.map +1 -1
  104. package/dist/settings-change.js +70 -1
  105. package/dist/settings-change.js.map +1 -1
  106. package/dist/settings-effect.js +26 -4
  107. package/dist/settings-effect.js.map +1 -1
  108. package/dist/settings-http-confirmation.js +7 -0
  109. package/dist/settings-http-confirmation.js.map +1 -1
  110. package/dist/setup-form.d.ts +1 -1
  111. package/dist/setup-form.js +1 -1
  112. package/dist/status-emojis.d.ts +10 -3
  113. package/dist/status-emojis.js +11 -2
  114. package/dist/status-emojis.js.map +1 -1
  115. package/dist/tips.js +4 -4
  116. package/dist/tips.js.map +1 -1
  117. package/dist/transcript-delta-reader.d.ts +3 -3
  118. package/dist/transcript-delta-reader.js +8 -8
  119. package/dist/transcript-delta-reader.js.map +1 -1
  120. package/dist/transcript-monitor.d.ts +8 -0
  121. package/dist/transcript-monitor.js +26 -2
  122. package/dist/transcript-monitor.js.map +1 -1
  123. package/dist/transcript-sources.d.ts +54 -0
  124. package/dist/transcript-sources.js +188 -27
  125. package/dist/transcript-sources.js.map +1 -1
  126. package/dist/transcript-turns.d.ts +15 -2
  127. package/dist/transcript-turns.js +15 -2
  128. package/dist/transcript-turns.js.map +1 -1
  129. package/dist/types.d.ts +11 -0
  130. package/dist/ui/chat-emojis.js +33 -0
  131. package/dist/ui/chat-render.js +12 -0
  132. package/dist/ui/chat-store.js +1 -1
  133. package/dist/ui/chat-strings.js +12 -12
  134. package/dist/ui/chat-thread.js +23 -4
  135. package/dist/ui/panel-chat.js +16 -3
  136. package/dist/ui/panel-needs.js +5 -2
  137. package/dist/ui/panel-settings.js +129 -31
  138. package/dist/ui/settings-dialogs.js +7 -2
  139. package/dist/ui/settings-model.js +2 -1
  140. package/dist/ui/settings-strings.js +44 -4
  141. package/dist/ui/shared/app-i18n.js +37 -15
  142. package/dist/ui/shared/app-prefs.js +249 -0
  143. package/dist/ui/shared/app-route.js +1 -1
  144. package/dist/ui/shared/app-shell.js +17 -4
  145. package/dist/ui/shared/app-store.js +3 -1
  146. package/dist/ui/shared/app.css +55 -0
  147. package/dist/ui/shared/app.js +6 -1
  148. package/dist/ui/shared/header-tools.js +55 -5
  149. package/dist/ui/shared/instance-nav.js +169 -32
  150. package/dist/ui/shared/panel-view.js +6 -0
  151. package/dist/ui/shared/signin.js +7 -7
  152. package/dist/ui/shared/theme.js +39 -7
  153. package/dist/ui/shared/ui-icons.js +1 -0
  154. package/dist/ui/shared/usage-dialog.js +13 -4
  155. package/dist/ui/shared/view-strings.js +22 -4
  156. package/dist/ui/signin.html +1 -1
  157. package/dist/update-install.d.ts +15 -1
  158. package/dist/update-install.js +6 -0
  159. package/dist/update-install.js.map +1 -1
  160. package/dist/update-preflight.d.ts +100 -0
  161. package/dist/update-preflight.js +264 -0
  162. package/dist/update-preflight.js.map +1 -0
  163. package/dist/updater-caller.d.ts +31 -0
  164. package/dist/updater-caller.js +109 -0
  165. package/dist/updater-caller.js.map +1 -0
  166. package/dist/view-api.d.ts +1 -1
  167. package/dist/view-api.js +38 -8
  168. package/dist/view-api.js.map +1 -1
  169. package/dist/wake-coordinator.d.ts +11 -0
  170. package/dist/wake-coordinator.js +38 -4
  171. package/dist/wake-coordinator.js.map +1 -1
  172. package/dist/web-api.d.ts +21 -1
  173. package/dist/web-api.js +157 -1
  174. package/dist/web-api.js.map +1 -1
  175. package/dist/web-auth.d.ts +2 -2
  176. package/dist/web-auth.js +7 -4
  177. package/dist/web-auth.js.map +1 -1
  178. package/dist/web-host-guard.d.ts +3 -11
  179. package/dist/web-host-guard.js +3 -12
  180. package/dist/web-host-guard.js.map +1 -1
  181. package/dist/web-images.d.ts +47 -0
  182. package/dist/web-images.js +169 -0
  183. package/dist/web-images.js.map +1 -0
  184. package/dist/web-prefs.d.ts +63 -0
  185. package/dist/web-prefs.js +263 -0
  186. package/dist/web-prefs.js.map +1 -0
  187. package/dist/web-session.d.ts +36 -1
  188. package/dist/web-session.js +53 -6
  189. package/dist/web-session.js.map +1 -1
  190. package/dist/web-settings-patch.d.ts +21 -0
  191. package/dist/web-settings-patch.js +74 -0
  192. package/dist/web-settings-patch.js.map +1 -0
  193. package/dist/web-shell-routes.d.ts +1 -1
  194. package/dist/web-shell-routes.js +1 -1
  195. package/dist/web-shell-routes.js.map +1 -1
  196. package/dist/workflow-templates/default.md +1 -1
  197. package/package.json +1 -1
@@ -1,14 +1,33 @@
1
1
  // #1408 §4: one dictionary for the app, namespaced (app.*, chat.*, fleet.*, …). A panel registers its own namespace
2
2
  // when it loads; t("chat.send") resolves it in the chosen language and falls back to English, then to the key.
3
- // The language is this browser's choice (agend_lang, the key every panel has used), else the device's.
3
+ // The language is the person's choice — kept on the host since #1665 and cached in this browser as agend_lang, the key
4
+ // every panel has used — else the device's.
4
5
 
5
6
  const tables = { en: {}, "zh-TW": {} };
6
7
  const listeners = new Set();
7
8
 
8
- function stored() {
9
- try { const v = localStorage.getItem("agend_lang"); return v === "en" || v === "zh-TW" ? v : null; } catch { return null; }
9
+ /** The device's own language: what applies when none is chosen. */
10
+ export function deviceLanguage() { return typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "") ? "zh-TW" : "en"; }
11
+ function cacheLang(v) { try { if (v) localStorage.setItem("agend_lang", v); else localStorage.removeItem("agend_lang"); } catch { /* this page only */ } }
12
+ /**
13
+ * #1665: the signed-in page carries the host's values (<html data-prefs>) — the same rule as theme.js: the host's
14
+ * language is this page's (the browser's copy is refreshed, best effort); none chosen on the host and this device in
15
+ * step with it: the device's language; not in step yet, or no data-prefs: this browser's copy, else the device's.
16
+ */
17
+ function initialLang() {
18
+ let d = null;
19
+ try { d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null"); } catch { d = null; }
20
+ if (d && typeof d === "object" && d.values && typeof d.values === "object") {
21
+ const host = d.values.lang;
22
+ if (host === "en" || host === "zh-TW") { cacheLang(host); return host; }
23
+ let inStep = false;
24
+ try { inStep = localStorage.getItem("agend_prefs_synced") === "1"; } catch { inStep = false; }
25
+ if (inStep) { cacheLang(null); return deviceLanguage(); }
26
+ }
27
+ try { const v = localStorage.getItem("agend_lang"); if (v === "en" || v === "zh-TW") return v; } catch { /* none */ }
28
+ return deviceLanguage();
10
29
  }
11
- let current = stored() || (typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "") ? "zh-TW" : "en");
30
+ let current = initialLang();
12
31
 
13
32
  /** Add a namespace: `register("chat", { en: {...}, "zh-TW": {...} })`. */
14
33
  export function register(ns, byLang) {
@@ -19,11 +38,14 @@ export function register(ns, byLang) {
19
38
 
20
39
  export function lang() { return current; }
21
40
 
22
- /** Choose a language for this browser; every subscriber re-renders (a new navigation lease, §4). */
23
- export function setLang(next) {
41
+ /**
42
+ * Choose a language; every subscriber re-renders (a new navigation lease, §4). `{ store: false }`: this page only —
43
+ * the host's "none chosen" arriving from another device (app-prefs.js), which leaves no copy in this browser.
44
+ */
45
+ export function setLang(next, opts = {}) {
24
46
  if (next !== "en" && next !== "zh-TW") return;
25
47
  current = next;
26
- try { localStorage.setItem("agend_lang", next); } catch { /* this page only */ }
48
+ if (opts.store !== false) cacheLang(next);
27
49
  if (typeof document !== "undefined" && document.documentElement) document.documentElement.lang = next === "zh-TW" ? "zh-Hant" : "en";
28
50
  for (const fn of listeners) fn(next);
29
51
  }
@@ -38,13 +60,13 @@ export function t(key, ...values) {
38
60
 
39
61
  register("app", {
40
62
  en: {
41
- brand: "AgEnD", fleet: "Fleet", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
63
+ brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet overview", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
42
64
  collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
43
65
  theme: "Theme", themeSystem: "System", themeLight: "Light", themeDark: "Dark", language: "Language",
44
- previews: "Allow HTML previews on this device", tour: "Tour", tourTitle: "Show the web chat tour again",
66
+ previews: "Allow HTML previews", tour: "Tour", tourTitle: "Show the web chat tour again",
45
67
  needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
46
- textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
47
- statusRunning: "running", statusStopped: "stopped", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
68
+ textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage", usageHide: "Hide usage",
69
+ statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
48
70
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
49
71
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
50
72
  skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
@@ -67,13 +89,13 @@ register("app", {
67
89
  needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed",
68
90
  },
69
91
  "zh-TW": {
70
- brand: "AgEnD", fleet: "Fleet", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
92
+ brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
71
93
  collapse: "隱藏側邊欄", expand: "顯示側邊欄", openMenu: "打開側邊欄", closeMenu: "關閉側邊欄",
72
94
  theme: "主題", themeSystem: "跟隨系統", themeLight: "淺色", themeDark: "深色", language: "語言",
73
- previews: "允許這台裝置預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
95
+ previews: "允許預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
74
96
  needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
75
- textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
76
- statusRunning: "執行中", statusStopped: "已停止", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
97
+ textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量", usageHide: "隱藏用量",
98
+ statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
77
99
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
78
100
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
79
101
  skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
4
  export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
5
- export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "advanced"]; // #1604: device first
5
+ export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
8
8
 
@@ -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. */
@@ -103,6 +103,22 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
103
103
  .inst:hover { background: var(--surface-2); }
104
104
  .inst.active { background: var(--surface-2); }
105
105
  .inst .dot { margin-top: 8px; }
106
+ /* #1630: the avatar (a picture, an emoji or the first character), its status dot, and an optional ring in its bot's colour (--ring, set through the CSSOM). */
107
+ .av { position: relative; flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--surface-2); color: var(--text-2);
108
+ display: inline-flex; align-items: center; justify-content: center; font-size: var(--fs-sm); font-weight: var(--fw-semibold); line-height: 1; }
109
+ .av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
110
+ .av.av-emoji { background: transparent; font-size: 18px; font-weight: normal; }
111
+ .av.av-emoji img { width: 22px; height: 22px; border-radius: 0; object-fit: contain; }
112
+ .av.ring { box-shadow: 0 0 0 2px var(--ring, transparent); }
113
+ .av-text { overflow: hidden; }
114
+ .av .av-dot { position: absolute; right: -2px; bottom: -2px; margin: 0; box-shadow: 0 0 0 2px var(--bg-sidebar); }
115
+ .inst.active .av .av-dot, .inst:hover .av .av-dot { box-shadow: 0 0 0 2px var(--surface-2); }
116
+ .dot.hollow { background: transparent; border: 1.5px solid var(--text-3); }
117
+ /* On a grey avatar a grey dot needs more contrast: stopped is a stronger grey, paused a clear ring around the sidebar colour. */
118
+ .av .av-dot.off { background: var(--text-2); }
119
+ .av .av-dot.hollow { box-sizing: border-box; width: 10px; height: 10px; background: var(--bg-sidebar); border: 2.5px solid var(--text-2); }
120
+ .inst .av { margin-top: 1px; }
121
+ .inst-id { font-family: var(--font-mono); }
106
122
  .inst-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
107
123
  .inst-name { font-size: var(--fs-sm); line-height: var(--lh-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
124
  .inst.active .inst-name { font-weight: var(--fw-semibold); }
@@ -128,6 +144,13 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
128
144
  .s-device { display: flex; flex-direction: column; gap: var(--s-3); }
129
145
  .s-device .sub { margin: 0; color: var(--text-3); font-size: var(--fs-sm); }
130
146
  .s-device-row { display: flex; gap: var(--s-2); }
147
+ /* #1620: Settings → Web — the lead, and the read-only list of what is set in fleet.yaml. */
148
+ .s-web .sub { margin: 0; color: var(--text-3); font-size: var(--fs-sm); }
149
+ .s-web-fields:disabled select { opacity: 0.6; } /* over the public link: dimmed like the inputs (.field input:disabled) */
150
+ .s-web-ro { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s-1) var(--s-3); margin: 0; font-size: var(--fs-sm); }
151
+ .s-web-ro > div { display: contents; }
152
+ .s-web-ro dt { color: var(--text-3); }
153
+ .s-web-ro dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
131
154
  .prefs { display: flex; gap: var(--s-2); padding: var(--s-1) var(--s-3); }
132
155
  .pref { display: flex; align-items: center; gap: var(--s-1); color: var(--text-2); flex: 1; min-width: 0; }
133
156
  .pref select { flex: 1; min-width: 0; height: 30px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text-2); font-size: var(--fs-xs); cursor: pointer; }
@@ -297,6 +320,12 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
297
320
  .msg.user .meta { justify-content: flex-end; }
298
321
  .msg.user .body { background: var(--user-bubble); border-radius: var(--r-xl); padding: 10px var(--s-4); }
299
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); }
300
329
  .msg-tools { display: flex; gap: var(--s-1); margin-top: var(--s-1); opacity: 0; transition: opacity var(--t-fast); }
301
330
  .msg:hover .msg-tools, .msg:focus-within .msg-tools { opacity: 1; }
302
331
  @media (hover: none) { .msg-tools { opacity: 1; } }
@@ -566,6 +595,24 @@ html.lb-open .scroller { overflow: hidden; }
566
595
  .caret.folded { transform: rotate(-90deg); }
567
596
  .caret.flipped { transform: rotate(180deg); } /* the card's toggle: up when closed (it opens upwards) */
568
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; } }
569
616
  .v-inst .dot { margin-top: 0; }
570
617
  .v-meta { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
571
618
  .cli-icon { width: 10px; height: 10px; flex: 0 0 10px; border-radius: 50%; border: 1px solid var(--border-strong); }
@@ -637,6 +684,12 @@ html.lb-open .scroller { overflow: hidden; }
637
684
  .u-sub { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
638
685
  .u-err { color: var(--warn); font-size: var(--fs-sm); }
639
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; } }
640
693
  @media (max-width: 899px) {
641
694
  .hide-narrow { display: none; }
642
695
  .v-card { padding-left: var(--s-3); padding-right: var(--s-3); }
@@ -934,3 +987,5 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
934
987
  .cmd-palette .cmd-desc { white-space: normal; flex-basis: 100%; }
935
988
  .cmd-choices .btn, .quick-actions .btn { min-height: var(--tap); }
936
989
  }
990
+ /* #1630: a Discord server emoji in a chat message, the size of the text around it. */
991
+ .md img.emoji { display: inline-block; width: 1.375em; height: 1.375em; object-fit: contain; vertical-align: -0.3em; margin: 0 0.05em; }
@@ -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;