@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.10

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 (351) hide show
  1. package/dist/agent-cli-instructions.md +5 -2
  2. package/dist/agent-cli.js +43 -7
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +14 -6
  7. package/dist/agent-file-read.js +207 -19
  8. package/dist/agent-file-read.js.map +1 -1
  9. package/dist/agent-whats-new.d.ts +11 -0
  10. package/dist/agent-whats-new.js +29 -15
  11. package/dist/agent-whats-new.js.map +1 -1
  12. package/dist/auth-api.d.ts +30 -1
  13. package/dist/auth-api.js +45 -13
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/authority/authority-state.d.ts +38 -0
  16. package/dist/authority/authority-state.js +114 -0
  17. package/dist/authority/authority-state.js.map +1 -0
  18. package/dist/authority/session-authorizations.d.ts +43 -0
  19. package/dist/authority/session-authorizations.js +102 -0
  20. package/dist/authority/session-authorizations.js.map +1 -0
  21. package/dist/authority/web-authority.d.ts +120 -0
  22. package/dist/authority/web-authority.js +157 -0
  23. package/dist/authority/web-authority.js.map +1 -0
  24. package/dist/backend/claude-code.d.ts +2 -2
  25. package/dist/backend/claude-code.js +10 -5
  26. package/dist/backend/claude-code.js.map +1 -1
  27. package/dist/backend/types.d.ts +14 -0
  28. package/dist/backend/types.js +25 -0
  29. package/dist/backend/types.js.map +1 -1
  30. package/dist/backend-availability.d.ts +120 -0
  31. package/dist/backend-availability.js +396 -0
  32. package/dist/backend-availability.js.map +1 -0
  33. package/dist/backend-command.d.ts +21 -3
  34. package/dist/backend-command.js +23 -4
  35. package/dist/backend-command.js.map +1 -1
  36. package/dist/bundled-skills.d.ts +14 -0
  37. package/dist/bundled-skills.js +29 -0
  38. package/dist/bundled-skills.js.map +1 -0
  39. package/dist/cache-analysis.d.ts +47 -1
  40. package/dist/cache-analysis.js +68 -8
  41. package/dist/cache-analysis.js.map +1 -1
  42. package/dist/cache-ledger.d.ts +16 -3
  43. package/dist/cache-ledger.js +33 -13
  44. package/dist/cache-ledger.js.map +1 -1
  45. package/dist/cache-report-tool.d.ts +17 -0
  46. package/dist/cache-report-tool.js +40 -0
  47. package/dist/cache-report-tool.js.map +1 -0
  48. package/dist/cache-service.d.ts +70 -3
  49. package/dist/cache-service.js +178 -17
  50. package/dist/cache-service.js.map +1 -1
  51. package/dist/channel/adapters/discord.d.ts +7 -0
  52. package/dist/channel/adapters/discord.js +35 -0
  53. package/dist/channel/adapters/discord.js.map +1 -1
  54. package/dist/channel/mcp-tools.js +22 -10
  55. package/dist/channel/mcp-tools.js.map +1 -1
  56. package/dist/channel/types.d.ts +6 -0
  57. package/dist/classic-channel-manager.d.ts +10 -5
  58. package/dist/classic-channel-manager.js +12 -9
  59. package/dist/classic-channel-manager.js.map +1 -1
  60. package/dist/cli.js +130 -31
  61. package/dist/cli.js.map +1 -1
  62. package/dist/config-validator.js +103 -4
  63. package/dist/config-validator.js.map +1 -1
  64. package/dist/daemon.d.ts +7 -0
  65. package/dist/daemon.js +63 -16
  66. package/dist/daemon.js.map +1 -1
  67. package/dist/decision-list.d.ts +25 -0
  68. package/dist/decision-list.js +30 -0
  69. package/dist/decision-list.js.map +1 -0
  70. package/dist/delivery-outbox.d.ts +10 -0
  71. package/dist/delivery-outbox.js +12 -1
  72. package/dist/delivery-outbox.js.map +1 -1
  73. package/dist/fleet-context.d.ts +10 -0
  74. package/dist/fleet-manager.d.ts +417 -11
  75. package/dist/fleet-manager.js +1387 -109
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/fleet-traffic.d.ts +74 -0
  78. package/dist/fleet-traffic.js +196 -0
  79. package/dist/fleet-traffic.js.map +1 -0
  80. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  81. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  82. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  83. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  84. package/dist/general-knowledge/skills/web-appearance/SKILL.md +257 -23
  85. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +476 -0
  86. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  87. package/dist/human-delivery-receipts.d.ts +24 -1
  88. package/dist/human-delivery-receipts.js +64 -11
  89. package/dist/human-delivery-receipts.js.map +1 -1
  90. package/dist/instance-config-impact.js +3 -1
  91. package/dist/instance-config-impact.js.map +1 -1
  92. package/dist/instance-names.d.ts +4 -0
  93. package/dist/instance-names.js +12 -0
  94. package/dist/instance-names.js.map +1 -0
  95. package/dist/instructions.d.ts +12 -1
  96. package/dist/instructions.js +13 -2
  97. package/dist/instructions.js.map +1 -1
  98. package/dist/locale.js +100 -8
  99. package/dist/locale.js.map +1 -1
  100. package/dist/ls-attach-resolve.d.ts +95 -0
  101. package/dist/ls-attach-resolve.js +174 -0
  102. package/dist/ls-attach-resolve.js.map +1 -0
  103. package/dist/memory-hogs.d.ts +27 -0
  104. package/dist/memory-hogs.js +124 -0
  105. package/dist/memory-hogs.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +10 -0
  107. package/dist/outbound-handlers.js +122 -2
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +164 -4
  110. package/dist/outbound-schemas.js +99 -3
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/public-web-gateway.js +8 -2
  113. package/dist/public-web-gateway.js.map +1 -1
  114. package/dist/public-web-link.d.ts +9 -0
  115. package/dist/public-web-link.js +8 -0
  116. package/dist/public-web-link.js.map +1 -1
  117. package/dist/quickstart-api.d.ts +10 -1
  118. package/dist/quickstart-api.js +12 -4
  119. package/dist/quickstart-api.js.map +1 -1
  120. package/dist/reply-buttons-controller.d.ts +4 -0
  121. package/dist/reply-buttons-controller.js +2 -2
  122. package/dist/reply-buttons-controller.js.map +1 -1
  123. package/dist/restart-progress.d.ts +65 -1
  124. package/dist/restart-progress.js +236 -10
  125. package/dist/restart-progress.js.map +1 -1
  126. package/dist/rollout-index.d.ts +5 -0
  127. package/dist/rollout-index.js +26 -5
  128. package/dist/rollout-index.js.map +1 -1
  129. package/dist/scheduler/scheduler.d.ts +12 -0
  130. package/dist/scheduler/scheduler.js +83 -10
  131. package/dist/scheduler/scheduler.js.map +1 -1
  132. package/dist/service-installer.d.ts +2 -1
  133. package/dist/service-installer.js +18 -3
  134. package/dist/service-installer.js.map +1 -1
  135. package/dist/settings-api.d.ts +19 -0
  136. package/dist/settings-api.js +137 -2
  137. package/dist/settings-api.js.map +1 -1
  138. package/dist/settings-change.js +104 -0
  139. package/dist/settings-change.js.map +1 -1
  140. package/dist/settings-effect.js +17 -1
  141. package/dist/settings-effect.js.map +1 -1
  142. package/dist/tmux-manager.d.ts +5 -0
  143. package/dist/tmux-manager.js +16 -0
  144. package/dist/tmux-manager.js.map +1 -1
  145. package/dist/tool-permissions.d.ts +4 -6
  146. package/dist/tool-permissions.js +53 -5
  147. package/dist/tool-permissions.js.map +1 -1
  148. package/dist/topic-commands.d.ts +7 -1
  149. package/dist/topic-commands.js +18 -4
  150. package/dist/topic-commands.js.map +1 -1
  151. package/dist/types.d.ts +48 -1
  152. package/dist/ui/app-effects.js +77 -31
  153. package/dist/ui/chat-ambient.js +307 -0
  154. package/dist/ui/chat-effect-posters.js +8 -8
  155. package/dist/ui/chat-effects.js +101 -13
  156. package/dist/ui/chat-render.js +5 -2
  157. package/dist/ui/chat-strings.js +59 -6
  158. package/dist/ui/chat-thread.js +149 -21
  159. package/dist/ui/effect-trial-controller.js +13 -8
  160. package/dist/ui/effect-trial-panel.js +19 -16
  161. package/dist/ui/fleet-cache.js +23 -1
  162. package/dist/ui/fleet-canvas-layout.js +111 -0
  163. package/dist/ui/fleet-canvas.js +525 -0
  164. package/dist/ui/fleet-org.js +27 -12
  165. package/dist/ui/fleet-traffic.js +343 -0
  166. package/dist/ui/fleet-usage.js +401 -0
  167. package/dist/ui/panel-chat.js +127 -49
  168. package/dist/ui/panel-details.js +2 -1
  169. package/dist/ui/panel-fleet.js +41 -16
  170. package/dist/ui/panel-settings.js +26 -8
  171. package/dist/ui/preview.js +3 -2
  172. package/dist/ui/settings-appearance.js +42 -18
  173. package/dist/ui/settings-catalog.js +92 -0
  174. package/dist/ui/settings-dialogs.js +40 -12
  175. package/dist/ui/settings-effects.js +4 -3
  176. package/dist/ui/settings-guests.js +117 -0
  177. package/dist/ui/settings-learn.js +25 -0
  178. package/dist/ui/settings-model.js +15 -6
  179. package/dist/ui/settings-mods.js +23 -9
  180. package/dist/ui/settings-owner-effects.js +9 -9
  181. package/dist/ui/settings-strings.js +120 -42
  182. package/dist/ui/settings-web-authority.js +91 -0
  183. package/dist/ui/shared/agend-auth.js +1 -1
  184. package/dist/ui/shared/app-i18n.js +4 -2
  185. package/dist/ui/shared/app-prefs.js +5 -4
  186. package/dist/ui/shared/app-route.js +1 -1
  187. package/dist/ui/shared/app-session.js +2 -0
  188. package/dist/ui/shared/app-shell.js +75 -9
  189. package/dist/ui/shared/app-store.js +26 -1
  190. package/dist/ui/shared/app-stream.js +3 -1
  191. package/dist/ui/shared/app-themes.js +24 -6
  192. package/dist/ui/shared/app.css +364 -16
  193. package/dist/ui/shared/app.js +21 -14
  194. package/dist/ui/shared/card-strings.js +4 -0
  195. package/dist/ui/shared/effect-app-runtime.js +68 -19
  196. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  197. package/dist/ui/shared/effect-frame.js +26 -7
  198. package/dist/ui/shared/effect-geometry.js +24 -9
  199. package/dist/ui/shared/effect-policy.js +21 -7
  200. package/dist/ui/shared/effect-profiler.js +31 -9
  201. package/dist/ui/shared/effect-registry.js +87 -48
  202. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  203. package/dist/ui/shared/effect-stock-trust.js +62 -10
  204. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  205. package/dist/ui/shared/effect-trial-source.js +1 -1
  206. package/dist/ui/shared/effect-trial-stage.js +54 -31
  207. package/dist/ui/shared/effect-trial.css +40 -0
  208. package/dist/ui/shared/fx-motion.js +67 -0
  209. package/dist/ui/shared/identity-card.js +75 -4
  210. package/dist/ui/shared/identity-color.js +3 -3
  211. package/dist/ui/shared/instance-nav.js +194 -28
  212. package/dist/ui/shared/panel-view.js +24 -4
  213. package/dist/ui/shared/tokens.css +9 -3
  214. package/dist/ui/shared/ui-icons.js +1 -0
  215. package/dist/ui/shared/usage-dialog.js +22 -2
  216. package/dist/ui/shared/view-strings.js +68 -2
  217. package/dist/ui/shared/web-authorize.js +151 -0
  218. package/dist/ui/signin.html +1 -1
  219. package/dist/ui/web-terminal/terminal.css +4 -4
  220. package/dist/usage/providers.d.ts +28 -0
  221. package/dist/usage/providers.js +126 -36
  222. package/dist/usage/providers.js.map +1 -1
  223. package/dist/usage-report.d.ts +158 -0
  224. package/dist/usage-report.js +226 -0
  225. package/dist/usage-report.js.map +1 -0
  226. package/dist/usage-rollup.d.ts +51 -0
  227. package/dist/usage-rollup.js +168 -0
  228. package/dist/usage-rollup.js.map +1 -0
  229. package/dist/view-api.d.ts +39 -0
  230. package/dist/view-api.js +65 -4
  231. package/dist/view-api.js.map +1 -1
  232. package/dist/web-ambient.d.ts +72 -0
  233. package/dist/web-ambient.js +161 -0
  234. package/dist/web-ambient.js.map +1 -0
  235. package/dist/web-api.d.ts +69 -5
  236. package/dist/web-api.js +362 -45
  237. package/dist/web-api.js.map +1 -1
  238. package/dist/web-appearance-policy.d.ts +4 -6
  239. package/dist/web-appearance-policy.js +5 -12
  240. package/dist/web-appearance-policy.js.map +1 -1
  241. package/dist/web-appearance-tools.d.ts +3 -0
  242. package/dist/web-appearance-tools.js +22 -3
  243. package/dist/web-appearance-tools.js.map +1 -1
  244. package/dist/web-appearance-view.d.ts +27 -2
  245. package/dist/web-appearance-view.js +129 -14
  246. package/dist/web-appearance-view.js.map +1 -1
  247. package/dist/web-appearance.d.ts +17 -3
  248. package/dist/web-appearance.js +82 -7
  249. package/dist/web-appearance.js.map +1 -1
  250. package/dist/web-asset-parts.d.ts +5 -10
  251. package/dist/web-asset-parts.js +3 -3
  252. package/dist/web-asset-parts.js.map +1 -1
  253. package/dist/web-auth.js +3 -1
  254. package/dist/web-auth.js.map +1 -1
  255. package/dist/web-card.d.ts +14 -0
  256. package/dist/web-card.js +40 -6
  257. package/dist/web-card.js.map +1 -1
  258. package/dist/web-channel-echo.d.ts +7 -1
  259. package/dist/web-channel-echo.js +6 -2
  260. package/dist/web-channel-echo.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +46 -0
  262. package/dist/web-chat-history.js +80 -3
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-commands.d.ts +1 -0
  265. package/dist/web-effect-bootstrap.d.ts +12 -0
  266. package/dist/web-effect-bootstrap.js +49 -0
  267. package/dist/web-effect-bootstrap.js.map +1 -0
  268. package/dist/web-effect-bundle.js +46 -1
  269. package/dist/web-effect-bundle.js.map +1 -1
  270. package/dist/web-effect-playback.d.ts +30 -0
  271. package/dist/web-effect-playback.js +71 -0
  272. package/dist/web-effect-playback.js.map +1 -0
  273. package/dist/web-effect-profiles.d.ts +4 -4
  274. package/dist/web-effect-service.d.ts +11 -4
  275. package/dist/web-effect-service.js +28 -5
  276. package/dist/web-effect-service.js.map +1 -1
  277. package/dist/web-effect-tools.d.ts +1 -0
  278. package/dist/web-effect-tools.js +8 -5
  279. package/dist/web-effect-tools.js.map +1 -1
  280. package/dist/web-effect-trials.d.ts +4 -4
  281. package/dist/web-effect-trials.js +6 -6
  282. package/dist/web-effect-trials.js.map +1 -1
  283. package/dist/web-effect-upload.d.ts +2 -4
  284. package/dist/web-effect-upload.js +3 -8
  285. package/dist/web-effect-upload.js.map +1 -1
  286. package/dist/web-effect-worker.d.ts +1 -1
  287. package/dist/web-effect-worker.js +2 -2
  288. package/dist/web-effect-worker.js.map +1 -1
  289. package/dist/web-effect.d.ts +14 -0
  290. package/dist/web-effect.js +21 -0
  291. package/dist/web-effect.js.map +1 -1
  292. package/dist/web-guest-access.d.ts +125 -0
  293. package/dist/web-guest-access.js +234 -0
  294. package/dist/web-guest-access.js.map +1 -0
  295. package/dist/web-guest-rooms.d.ts +82 -0
  296. package/dist/web-guest-rooms.js +135 -0
  297. package/dist/web-guest-rooms.js.map +1 -0
  298. package/dist/web-identity-color.d.ts +1 -1
  299. package/dist/web-identity-color.js +2 -2
  300. package/dist/web-identity-color.js.map +1 -1
  301. package/dist/web-identity-palette.d.ts +1 -1
  302. package/dist/web-identity-palette.js +2 -2
  303. package/dist/web-identity-palette.js.map +1 -1
  304. package/dist/web-login.d.ts +47 -0
  305. package/dist/web-login.js +115 -0
  306. package/dist/web-login.js.map +1 -1
  307. package/dist/web-mod-agent.d.ts +1 -1
  308. package/dist/web-mod-agent.js +12 -8
  309. package/dist/web-mod-agent.js.map +1 -1
  310. package/dist/web-mod-bundle.d.ts +14 -1
  311. package/dist/web-mod-bundle.js +68 -3
  312. package/dist/web-mod-bundle.js.map +1 -1
  313. package/dist/web-mod-export.d.ts +9 -0
  314. package/dist/web-mod-export.js +21 -0
  315. package/dist/web-mod-export.js.map +1 -1
  316. package/dist/web-mod-store.d.ts +7 -1
  317. package/dist/web-mod-store.js +21 -7
  318. package/dist/web-mod-store.js.map +1 -1
  319. package/dist/web-prefs.d.ts +14 -0
  320. package/dist/web-prefs.js +24 -4
  321. package/dist/web-prefs.js.map +1 -1
  322. package/dist/web-preview.d.ts +6 -2
  323. package/dist/web-preview.js +4 -2
  324. package/dist/web-preview.js.map +1 -1
  325. package/dist/web-returning-device.d.ts +41 -0
  326. package/dist/web-returning-device.js +57 -5
  327. package/dist/web-returning-device.js.map +1 -1
  328. package/dist/web-session-authorization.d.ts +132 -0
  329. package/dist/web-session-authorization.js +193 -0
  330. package/dist/web-session-authorization.js.map +1 -0
  331. package/dist/web-session.d.ts +70 -10
  332. package/dist/web-session.js +137 -22
  333. package/dist/web-session.js.map +1 -1
  334. package/dist/web-settings-patch.d.ts +49 -2
  335. package/dist/web-settings-patch.js +145 -1
  336. package/dist/web-settings-patch.js.map +1 -1
  337. package/dist/web-settings-tools.d.ts +89 -0
  338. package/dist/web-settings-tools.js +270 -0
  339. package/dist/web-settings-tools.js.map +1 -0
  340. package/dist/web-shell-routes.d.ts +1 -1
  341. package/dist/web-shell-routes.js +1 -1
  342. package/dist/web-shell-routes.js.map +1 -1
  343. package/dist/web-theme-store.d.ts +39 -0
  344. package/dist/web-theme-store.js +122 -11
  345. package/dist/web-theme-store.js.map +1 -1
  346. package/dist/web-theme.d.ts +2 -0
  347. package/dist/web-theme.js +10 -1
  348. package/dist/web-theme.js.map +1 -1
  349. package/dist/workflow-templates/default.md +1 -1
  350. package/package.json +1 -1
  351. package/templates/systemd.service.ejs +8 -0
@@ -9,7 +9,7 @@
9
9
  // Public (/assets/): an anonymous View reader gets the same list, fed by View's roster read instead of status frames.
10
10
  import { html, useLayoutEffect, useRef, useState } from "./app-html.js";
11
11
  import { t } from "./app-i18n.js";
12
- import { appStore, createStore, useStore } from "./app-store.js";
12
+ import { appStore, createStore, guestPage, useStore } from "./app-store.js";
13
13
  import { chatPath, detailsPath, logPath, viewPath } from "./app-route.js";
14
14
  import { Icon } from "./ui-icons.js";
15
15
  import { Skeleton } from "./ui-states.js";
@@ -30,12 +30,52 @@ const stored = (k) => { try { return localStorage.getItem(k); } catch { return n
30
30
  const nameOf = (it) => (it && (it.name != null ? it.name : it.instance_name)) || "";
31
31
 
32
32
  // ── Groups, labels, the filter ──
33
+ /** A team's group key: no tag can be it (a group name is never a control character), so a tag named like a team stays apart. */
34
+ export const TEAM_GROUP = "\u0001team:";
35
+ /**
36
+ * #2172: an agent in a team (fleet.yaml `teams.<name>.members`; `teams` on a status or roster row, in fleet.yaml's
37
+ * order) sits under its first team, whatever its tags say; one in no team under its first tag; a ClassicBot room under
38
+ * "Classic"; the rest under "Other".
39
+ */
33
40
  export function groupOf(it) {
34
41
  const tags = Array.isArray(it.tags) ? it.tags : [];
35
42
  if (tags.includes("classic")) return "Classic";
43
+ const teams = Array.isArray(it.teams) ? it.teams.filter((x) => typeof x === "string" && x) : [];
44
+ if (teams.length) return TEAM_GROUP + teams[0];
36
45
  if (tags.length) return tags[0];
37
46
  return "Other";
38
47
  }
48
+ /** "team" for a team's group, "tag" for a tag's; null for Classic, Other and Favorites. */
49
+ export function groupKind(g) {
50
+ if (typeof g !== "string") return null;
51
+ if (g.startsWith(TEAM_GROUP)) return "team";
52
+ return g === "Other" || g === "Classic" || g.charCodeAt(0) < 32 ? null : "tag";
53
+ }
54
+ /** A group's heading: a team's name; the fallback group ("Other") in the page's language; a tag as it is. */
55
+ export function groupLabel(g) { return g === "Other" ? tn("groupOther") : groupKind(g) === "team" ? g.slice(TEAM_GROUP.length) : g; }
56
+ /** #2172: what a group is, said on its heading — a team's icon, or "#" before a tag — so the grouping can be read. */
57
+ export function GroupMark({ group }) {
58
+ const kind = groupKind(group);
59
+ if (!kind) return null;
60
+ const what = tn(kind === "team" ? "groupTeam" : "groupTag");
61
+ return kind === "team"
62
+ ? html`<span class="v-group-kind team" title=${what}><${Icon} name="team" size=${13} /><span class="sr-only">${what}: </span></span>`
63
+ : html`<span class="v-group-kind tag" title=${what} aria-hidden="true">#</span>`;
64
+ }
65
+ /**
66
+ * #2050: the model as a person reads it. The server's resolver writes English for a model it has not resolved yet
67
+ * ("default (detected when it starts)") and for a CLI's own default ("x (default)"); those are shown in the page's language.
68
+ */
69
+ export function modelText(it, raw = it && it.model) {
70
+ if (!raw || typeof raw !== "string") return raw;
71
+ if (it.model_source === "unresolved") {
72
+ const why = /^default \((.*)\)$/.exec(raw)?.[1];
73
+ const reason = why === "detected when it starts" ? tn("modelWhyStart") : why === "this CLI does not report a default" ? tn("modelWhyCli") : why;
74
+ return reason ? tn("modelDefaultWhy", { reason }) : raw === "default" ? tn("modelDefault") : raw;
75
+ }
76
+ if (it.model_source === "cli-default") { const m = /^(.*) \(default\)$/.exec(raw); if (m) return tn("modelCliDefault", { model: m[1] }); }
77
+ return raw;
78
+ }
39
79
  const BACKEND_LABELS = { "claude-code": "Claude Code", "kiro-cli": "Kiro CLI", codex: "Codex", grok: "Grok Build", antigravity: "Antigravity", opencode: "OpenCode" };
40
80
  export function backendLabel(backend) { return BACKEND_LABELS[backend] || backend || "Unknown"; }
41
81
  export const KNOWN_BACKENDS = new Set(Object.keys(BACKEND_LABELS));
@@ -93,8 +133,8 @@ export function instanceTooltip(it) {
93
133
  if (it.display_name && it.display_name !== name) lines.push(it.display_name, `(${name})`);
94
134
  else lines.push(name);
95
135
  lines.push(line("tooltipBackend", backendLabel(it.backend)));
96
- const src = (s) => (s === "instance" || s === "classic" ? " (configured)" : s === "fleet-default" ? " (fleet default)" : "");
97
- lines.push(line("tooltipModel", (it.model || tn("tooltipUnavailable")) + src(it.model_source)));
136
+ const src = (s) => (s === "instance" || s === "classic" ? tn("srcConfigured") : s === "fleet-default" ? tn("srcFleetDefault") : "");
137
+ lines.push(line("tooltipModel", (modelText(it) || tn("tooltipUnavailable")) + src(it.model_source)));
98
138
  lines.push(line("tooltipStatus", status));
99
139
  lines.push(line("tooltipContext", context));
100
140
  if (it.effort) lines.push(line("tooltipEffort", it.effort + src(it.effort_source)));
@@ -108,6 +148,7 @@ const SIDEBAR_ORDER_KEY = "agend_view_sidebar_order"; // View's key since #999
108
148
  const COLLAPSED_KEY = "agend_instance_collapsed";
109
149
  const FILTER_KEY = "agend_instance_filter";
110
150
  const FAVORITES_KEY = "agend_favorites"; // #1664
151
+ const SORT_KEY = "agend_nav_sort"; // #2179
111
152
  /**
112
153
  * A synced sidebar preference, as the JSON its reader parses: the value in force in memory (app-prefs.js — this
113
154
  * page's own change, else the host's; a full or blocked storage never brings an old one back), else this browser's copy.
@@ -138,6 +179,13 @@ function readFilter() {
138
179
  return { q: f && typeof f.q === "string" ? f.q : "", status: strings(f && f.status).filter((s) => STATUS_FACETS.includes(s)), cli: strings(f && f.cli) };
139
180
  } catch { return { q: "", status: [], cli: [] }; }
140
181
  }
182
+ /** #2179: how the list sorts — { mode: "custom" (the person's order; the default) | "recent", favs: "fixed" | "recent" }. */
183
+ function readSort() {
184
+ try {
185
+ const s = JSON.parse(inForce("nav.sort", SORT_KEY) || "{}");
186
+ return { mode: s && s.mode === "recent" ? "recent" : "custom", favs: s && s.favs === "recent" ? "recent" : "fixed" };
187
+ } catch { return { mode: "custom", favs: "fixed" }; }
188
+ }
141
189
  function readFavorites() {
142
190
  try { const a = JSON.parse(inForce("nav.favorites", FAVORITES_KEY) || "[]"); return Array.isArray(a) ? [...new Set(a.filter((x) => typeof x === "string"))] : []; } catch { return []; }
143
191
  }
@@ -151,7 +199,9 @@ export function computeOrder(roster, order) {
151
199
  if (ia !== ib) return ia - ib;
152
200
  if (a === "Other") return 1;
153
201
  if (b === "Other") return -1;
154
- return a.localeCompare(b);
202
+ const ta = groupKind(a) === "team", tb = groupKind(b) === "team";
203
+ if (ta !== tb) return ta ? -1 : 1; // #2172: teams first, then tags
204
+ return groupLabel(a).localeCompare(groupLabel(b));
155
205
  });
156
206
  const instByGroup = new Map();
157
207
  for (const [g, items] of groups) {
@@ -174,12 +224,18 @@ function moveIn(arr, moving, target, before) {
174
224
  }
175
225
 
176
226
  /** The list's state for the page's life: this browser's order, folded groups and filter (each also kept in storage). */
177
- export const navStore = createStore({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites() });
227
+ export const navStore = createStore({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites(), sort: readSort() });
178
228
  /** Read them again from this browser's storage — what a page load does (the store above is made that way). */
179
229
  export function loadNavPrefs() {
180
- navStore.set({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites() });
230
+ navStore.set({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites(), sort: readSort() });
181
231
  namesStore.set({ primary: readNamesPrimary() });
182
232
  }
233
+ /** #2179: change how the list sorts ({ mode?, favs? }): the same on every page and device. */
234
+ export function setNavSort(patch) {
235
+ const sort = { ...navStore.get().sort, ...patch };
236
+ navStore.set({ sort });
237
+ savePref("nav.sort", { mode: sort.mode, favs: sort.favs });
238
+ }
183
239
  /** Change the filter ({ q?, status?, cli? }): the same on every page, and kept by this browser. */
184
240
  export function setNavFilter(patch) {
185
241
  const filter = { ...navStore.get().filter, ...patch };
@@ -188,7 +244,7 @@ export function setNavFilter(patch) {
188
244
  }
189
245
  function setCollapsed(collapsed) { navStore.set({ collapsed }); savePref("nav.collapsed", [...collapsed]); }
190
246
  // Another device rearranged, folded or filtered: this page's list follows (its cache is already updated).
191
- onPrefsChanged((keys) => { if (keys.has("nav.order") || keys.has("nav.collapsed") || keys.has("nav.filter") || keys.has("nav.favorites")) loadNavPrefs(); });
247
+ onPrefsChanged((keys) => { if (keys.has("nav.order") || keys.has("nav.collapsed") || keys.has("nav.filter") || keys.has("nav.favorites") || keys.has("nav.sort")) loadNavPrefs(); });
192
248
 
193
249
  // ── #1664: favorites — starred instances, pinned at the top in the order they were starred (or moved) ──
194
250
  export const MAX_FAVORITES = 50;
@@ -224,6 +280,8 @@ function writeFacetsOpen(open) { try { localStorage.setItem(FACETS_KEY, JSON.str
224
280
 
225
281
  /** The Favorites group's key among the tag groups: no tag can be it (a group name is never a control character). */
226
282
  const FAV_GROUP = "\u0000favorites";
283
+ /** #1936: the open instance, pinned on top while a filter is on. */
284
+ const PINNED_GROUP = "\u0000pinned";
227
285
  function persistOrder(groupNames, instByGroup) {
228
286
  const rows = [];
229
287
  groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
@@ -337,6 +395,71 @@ export function InstanceAvatar({ it, state, cli = false }) {
337
395
  const statusText = (facet) => (facet === "needs" ? t("app.needsYou") : facet === "working" ? t("app.statusWorking") : facet === "idle" ? t("app.statusIdle")
338
396
  : facet === "crashed" ? t("app.statusCrashed") : t("app.statusStopped"));
339
397
 
398
+ // ── #2179: when each instance last replied to a person (the status frame's last_reply) ──
399
+ /** A reply this recent (minutes) is highlighted; the highlight fades to nothing over this time. */
400
+ export const REPLY_FRESH_MIN = 10;
401
+ /** Minutes since the instance last replied to a person, or null (none kept, or no time on this row). */
402
+ export function replyAge(it, now = Date.now()) {
403
+ const at = it && typeof it.last_reply === "string" ? Date.parse(it.last_reply) : NaN;
404
+ return Number.isFinite(at) ? Math.max(0, (now - at) / 60000) : null;
405
+ }
406
+ /** "just now", "2 min ago", "3 h ago", "yesterday", "4 d ago" — in the page's language. */
407
+ export function replyText(mins) {
408
+ if (mins < 1) return tn("replyJustNow");
409
+ if (mins < 60) return tn("replyMinutes", { n: Math.floor(mins) });
410
+ if (mins < 1440) return tn("replyHours", { n: Math.floor(mins / 60) });
411
+ if (mins < 2880) return tn("replyYesterday");
412
+ return tn("replyDays", { n: Math.floor(mins / 1440) });
413
+ }
414
+ /** How strong the highlight is: 1 just after a reply, down to 0 at REPLY_FRESH_MIN minutes. */
415
+ export function replyFade(mins) { return mins == null || mins >= REPLY_FRESH_MIN ? 0 : 1 - mins / REPLY_FRESH_MIN; }
416
+ /** The highlight's strength on the row, through the CSSOM (style-src has no 'unsafe-inline'). */
417
+ function paintReply(el, fade) {
418
+ if (!el) return;
419
+ if (fade > 0) el.style.setProperty("--reply-a", fade.toFixed(3)); else el.style.removeProperty("--reply-a");
420
+ }
421
+ /** Most recent reply first; rows with none keep their order after them (a stable sort). */
422
+ function byRecentReply(age) {
423
+ return (a, b) => {
424
+ const x = age(a), y = age(b);
425
+ if (x == null || y == null) return x == null ? (y == null ? 0 : 1) : -1;
426
+ return x - y;
427
+ };
428
+ }
429
+ /** The group that holds every non-favorite row in "recent" mode: no tag or team can be it, and it is never saved. */
430
+ const RECENT_GROUP = "\u0000recent";
431
+
432
+ /** #2179: the sort button and its menu — how the list sorts, said on the button. */
433
+ function SortMenu({ sort }) {
434
+ const [open, setOpen] = useState(false);
435
+ const box = useRef(null);
436
+ useLayoutEffect(() => {
437
+ if (!open) return undefined;
438
+ const doc = box.current && box.current.ownerDocument;
439
+ if (!doc) return undefined;
440
+ const away = (e) => { if (box.current && !box.current.contains(e.target)) setOpen(false); };
441
+ const esc = (e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); } };
442
+ doc.addEventListener("pointerdown", away, true);
443
+ doc.addEventListener("keydown", esc, true);
444
+ return () => { doc.removeEventListener("pointerdown", away, true); doc.removeEventListener("keydown", esc, true); };
445
+ }, [open]);
446
+ const recent = sort.mode === "recent";
447
+ const radio = (group, value, label, hint) => html`<label class="nav-sort-opt"><input type="radio" name=${`nav-sort-${group}`} value=${value}
448
+ checked=${sort[group] === value} onChange=${() => setNavSort({ [group]: value })} /><span>${label}${hint ? html`<span class="nav-sort-hint">${hint}</span>` : null}</span></label>`;
449
+ return html`<div class="nav-sort" ref=${box}>
450
+ <button type="button" class=${`chip-btn nav-sort-btn${recent ? " on" : ""}`} aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"}
451
+ title=${tn("sortTitle")} onClick=${() => setOpen(!open)}><${Icon} name="sort" size=${12} /><span>${tn(recent ? "sortRecent" : "sortCustom")}</span></button>
452
+ ${open ? html`<div class="nav-sort-menu" role="dialog" aria-label=${tn("sortTitle")}>
453
+ <fieldset><legend>${tn("sortTitle")}</legend>
454
+ ${radio("mode", "custom", tn("sortCustom"), tn("sortCustomHint"))}
455
+ ${radio("mode", "recent", tn("sortRecent"), tn("sortRecentHint"))}</fieldset>
456
+ <fieldset><legend>${tn("sortFavs")}</legend>
457
+ ${radio("favs", "fixed", tn("sortFavsFixed"), "")}
458
+ ${radio("favs", "recent", tn("sortFavsRecent"), "")}</fieldset>
459
+ </div>` : null}
460
+ </div>`;
461
+ }
462
+
340
463
  /**
341
464
  * The sidebar's instance list. `route`: the page's (which instance is open, and which view a row opens). `items`: the
342
465
  * rows (status frames' instances, or View's roster for an anonymous reader); `loaded` false shows a skeleton.
@@ -350,19 +473,21 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
350
473
  const filterRef = useRef(null);
351
474
  const list = useRef(null);
352
475
  const drag = useRef(null);
476
+ // #2179: the reply times and their highlight move on by themselves — the list is drawn again every 30 s.
477
+ const [, setTick] = useState(0);
478
+ useLayoutEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 30000); return () => clearInterval(id); }, []);
353
479
  // #1700: which facet groups are open — this device's (screen layout, not a synced preference), read when the list mounts.
354
480
  const [facetOpen, setFacetOpen] = useState(readFacetsOpen);
355
481
  const toggleFacet = (key) => setFacetOpen((cur) => { const next = { ...cur, [key]: !cur[key] }; writeFacetsOpen(next); return next; });
356
482
  // #1664: "☆ Edit" — the stars on every row (on a phone the only way to them) and ↑ ↓ on the favorites — only on the
357
483
  // full list, as drag: in a filtered one the row above may not be the one above in the list (#1677 review).
358
484
  const [editing, setEditing] = useState(false);
359
- // Where a row goes: the view the page is in (#1523 §3.1). View → View; Details, or a Fleet tab (Q4 = A), → Details;
360
- // everywhere else → Chat. The anonymous reader only ever has View.
485
+ // Where a row goes: the view the page is in (#1523 §3.1). View → View; Details → Details; everywhere else, a Fleet tab
486
+ // included (#1917: the user's call, replacing Q4 = A), → Chat. The anonymous reader only ever has View.
361
487
  const rowPath = viewOnly || (route && route.panel === "view") ? viewPath
362
488
  : route && route.panel === "log" ? logPath // #1218: Log → Log
363
- : route && (route.panel === "details" || route.panel === "fleet") ? detailsPath : chatPath;
489
+ : route && route.panel === "details" ? detailsPath : chatPath;
364
490
  const active = route && (route.panel === "chat" || route.panel === "view" || route.panel === "log" || route.panel === "details") ? route.instance : null;
365
- useLayoutEffect(() => { keepActiveInView(list.current); }, [active, items.length, loaded]);
366
491
  const own = (map, name) => Object.prototype.hasOwnProperty.call(map, name);
367
492
  const byName = new Map(items.map((it) => [nameOf(it), it]));
368
493
  const facetOf = (name) => statusFacet(byName.get(name), own(exec, name) ? exec[name] : null, own(awaiting, name) ? awaiting[name] : null);
@@ -374,18 +499,44 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
374
499
  const favs = nav.favorites.filter((n) => known.has(n));
375
500
  const favSet = new Set(favs);
376
501
  const canStar = !viewOnly && prefsWritable();
377
- const shownGroups = favs.length ? [FAV_GROUP, ...groupNames] : groupNames;
378
- const shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n))]));
379
- if (favs.length) shownInsts.set(FAV_GROUP, favs);
380
- const f = nav.filter;
502
+ // #1920: a guest's list is to pick from — no reordering and no filters (one this device kept would hide rows with no
503
+ // way to clear it).
504
+ const guest = guestPage();
505
+ // #2179: when each row last replied (a guest's list has no times). "recent" mode (never a guest's): the favorites keep
506
+ // their order or are sorted too, and every other row is one list, the last reply first. "custom" (the default) is the
507
+ // person's own order — a reply never moves a row.
508
+ const now = Date.now();
509
+ // The anonymous View reader (viewOnly) has no reply times (/api/profiles carries none) and no way to change the sort:
510
+ // its list is always Custom, whatever this browser kept from a signed-in page (#2181 review).
511
+ const ageOf = (n) => (guest || viewOnly ? null : replyAge(byName.get(n), now));
512
+ const recent = !guest && !viewOnly && nav.sort.mode === "recent";
513
+ const favsSorted = recent && nav.sort.favs === "recent";
514
+ let shownGroups, shownInsts;
515
+ if (recent) {
516
+ const sortRecent = byRecentReply(ageOf);
517
+ const rest = groupNames.flatMap((g) => instByGroup.get(g) || []).filter((n) => !favSet.has(n)).sort(sortRecent);
518
+ shownGroups = [...(favs.length ? [FAV_GROUP] : []), ...(rest.length ? [RECENT_GROUP] : [])];
519
+ shownInsts = new Map([[FAV_GROUP, favsSorted ? [...favs].sort(sortRecent) : favs], [RECENT_GROUP, rest]]);
520
+ } else {
521
+ shownGroups = favs.length ? [FAV_GROUP, ...groupNames] : groupNames;
522
+ shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n))]));
523
+ if (favs.length) shownInsts.set(FAV_GROUP, favs);
524
+ }
525
+ const f = guest ? { q: "", status: [], cli: [] } : nav.filter;
381
526
  const shown = filterSidebar(shownGroups, shownInsts, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
527
+ // #1936: while a filter is on, the instance you have open stays on top, pinned and marked "Current", whether or not
528
+ // it matches; a match is not repeated below. "N / M" still counts the filter's matches (the pinned row counts only
529
+ // when it is one). Clearing the filter puts it back in its place, and the list scrolls to it again.
530
+ const pinned = shown.active && active && byName.has(active) ? active : null;
531
+ const listed = pinned ? shown.groups.map((x) => ({ ...x, names: x.names.filter((n) => n !== pinned) })).filter((x) => x.names.length) : shown.groups;
532
+ useLayoutEffect(() => { keepActiveInView(list.current); }, [active, items.length, loaded, shown.active]);
382
533
  const backends = [...new Set(items.map((it) => it.backend).filter((b) => typeof b === "string" && b))].sort((a, b) => backendLabel(a).localeCompare(backendLabel(b)));
383
534
  const toggle = (g) => { const c = new Set(navStore.get().collapsed); if (c.has(g)) c.delete(g); else c.add(g); setCollapsed(c); };
384
535
  // From the store, not this render's copy: two clicks before the next render must both count.
385
536
  const flip = (key, value) => { const cur = new Set(navStore.get().filter[key]); if (cur.has(value)) cur.delete(value); else cur.add(value); setNavFilter({ [key]: [...cur] }); };
386
537
  // Drag to reorder: only the full list (a filtered subset would be ambiguous), groups among groups, instances within
387
538
  // their group.
388
- const dragProps = (item) => shown.active ? {} : {
539
+ const dragProps = (item) => shown.active || guest || recent ? {} : { // #2179: not while sorted by reply
389
540
  draggable: true,
390
541
  onDragStart: (e) => { drag.current = item; e.currentTarget.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data */ } },
391
542
  onDragEnd: (e) => { e.currentTarget.classList.remove("dragging"); drag.current = null; },
@@ -440,18 +591,24 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
440
591
  // #1958: its identity colour — the ring, a left bar and/or the name, as resolved; painted through the CSSOM.
441
592
  const id = identityOf(it);
442
593
  const full = !starred && favs.length >= MAX_FAVORITES;
443
- return html`<li key=${name} class="v-li"><a class=${`inst v-inst${on ? " active" : ""} ${identityClasses(id)}`} ref=${(el) => paintIdentity(el, id)} href=${rowPath(name)} title=${instanceTooltip(it)}
594
+ // #2179 (B5, the user's pick): the % (and the ★ beside the row) on the name's line; the time of the last reply at the
595
+ // end of the second line, highlighted for REPLY_FRESH_MIN minutes, fading by itself (a reply under a minute pulses once).
596
+ const mins = ageOf(name), fade = replyFade(mins);
597
+ const two = !!secondary || mins != null;
598
+ return html`<li key=${name} class=${`v-li${two ? " two-line" : ""}`}><a class=${`inst v-inst${on ? " active" : ""}${fade > 0 ? " replied" : ""}${mins != null && mins < 1 ? " just" : ""} ${identityClasses(id)}`}
599
+ ref=${(el) => { paintIdentity(el, id); paintReply(el, fade); }} href=${rowPath(name)} title=${instanceTooltip(it)}
444
600
  aria-current=${on ? "page" : undefined} onClick=${onPick} ...${fav ? {} : dragProps({ type: "instance", name, group: g })}>
445
601
  <${InstanceAvatar} it=${it} state=${dotState(it, own(exec, name) ? exec[name] : null, waiting)} cli=${true} />
446
- <span class="inst-text"><span class="inst-name" title=${label}>${label}</span>
447
- ${secondary ? html`<span class="inst-alt" title=${secondary}>${secondary}</span>` : null}
602
+ <span class="inst-text"><span class="inst-line"><span class="inst-name" title=${label}>${label}</span>
603
+ <span class="v-meta">${it.context_pct != null ? `${Math.round(it.context_pct)}%` : ""}</span></span>
604
+ ${two ? html`<span class="inst-line inst-line2"><span class="inst-alt" title=${secondary || undefined}>${secondary || ""}</span>
605
+ ${mins != null ? html`<span class=${`inst-when${fade > 0 ? " fresh" : ""}`} title=${tn("replyTitle", { time: new Date(Date.parse(it.last_reply)).toLocaleString() })}>${replyText(mins)}</span>` : null}</span>` : null}
448
606
  ${facet === "needs" ? html`<span class="inst-sub"><span class="badge-await" title=${waiting || t("app.approxNote")}>${t("app.needsYou")}</span></span>` : null}
449
607
  <span class="sr-only">${tn("tooltipBackend", { value: backendLabel(it.backend) })}</span></span>
450
- <span class="v-meta">${it.context_pct != null ? `${Math.round(it.context_pct)}%` : ""}</span>
451
608
  </a>${fav && editing && canStar ? html`<button type="button" class="icon-btn inst-move" aria-label=${tn("favUp", { name: label })}
452
- title=${shown.active ? tn("favFiltered") : tn("favUp", { name: label })} disabled=${shown.active || i === 0} onClick=${() => { if (!shown.active) moveFavorite(name, -1, known); }}><${Icon} name="up" size=${14} /></button><button type="button" class="icon-btn inst-move"
453
- aria-label=${tn("favDown", { name: label })} title=${shown.active ? tn("favFiltered") : tn("favDown", { name: label })} disabled=${shown.active || i === count - 1}
454
- onClick=${() => { if (!shown.active) moveFavorite(name, 1, known); }}><${Icon} name="down" size=${14} /></button>` : null}${canStar
609
+ title=${shown.active ? tn("favFiltered") : favsSorted ? tn("favSorted") : tn("favUp", { name: label })} disabled=${shown.active || favsSorted || i === 0} onClick=${() => { if (!shown.active && !favsSorted) moveFavorite(name, -1, known); }}><${Icon} name="up" size=${14} /></button><button type="button" class="icon-btn inst-move"
610
+ aria-label=${tn("favDown", { name: label })} title=${shown.active ? tn("favFiltered") : favsSorted ? tn("favSorted") : tn("favDown", { name: label })} disabled=${shown.active || favsSorted || i === count - 1}
611
+ onClick=${() => { if (!shown.active && !favsSorted) moveFavorite(name, 1, known); }}><${Icon} name="down" size=${14} /></button>` : null}${canStar
455
612
  ? html`<button type="button" class=${`icon-btn inst-star${starred ? " on" : ""}`} aria-pressed=${starred ? "true" : "false"} disabled=${full}
456
613
  aria-label=${tn(starred ? "favRemove" : "favAdd", { name: label })} title=${full ? tn("favFull", { max: MAX_FAVORITES }) : tn(starred ? "favRemove" : "favAdd", { name: label })}
457
614
  onClick=${() => toggleFavorite(name, known)}><${Icon} name="star" size=${14} /></button>` : null}</li>`;
@@ -460,10 +617,19 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
460
617
  return html`<div class=${`view-roster inst-nav${editing && canStar ? " fav-editing" : ""}`}>
461
618
  <div data-trust="navigation" class="side-section" id="instanceList" ref=${list}>
462
619
  <div class="inst-head"><h2 class="side-label">${tn("instancesLabel")}</h2>
620
+ ${canStar && items.length && !guest ? html`<${SortMenu} sort=${nav.sort} />`
621
+ : recent ? html`<span class="chip-btn nav-sort-btn on nav-sort-static"><${Icon} name="sort" size=${12} /><span>${tn("sortRecent")}</span></span>` : null}
463
622
  ${canStar && items.length ? html`<button type="button" class="chip-btn fav-edit" aria-pressed=${editing ? "true" : "false"} onClick=${() => setEditing(!editing)}>
464
623
  ${editing ? tn("favDone") : html`<${Icon} name="star" size=${12} />${tn("favEdit")}`}</button>` : null}</div>
465
624
  ${!loaded ? html`<${Skeleton} lines=${4} />` : !items.length ? html`<p class="side-empty">${t("app.noInstances")}</p>` : null}
466
- ${shown.groups.map(({ group: g, names }) => {
625
+ ${pinned ? html`<div key=${PINNED_GROUP} class="v-group v-pinned" role="group" aria-label=${tn("pinnedLabel")}>
626
+ <div class="v-group-head pinned-head"><span class="grow">${tn("pinnedCurrent")}</span></div>
627
+ <ul class="inst-list">${row(pinned, PINNED_GROUP, 0, 1)}</ul></div>` : null}
628
+ ${listed.map(({ group: g, names }) => {
629
+ // #2179: in "recent" mode every other row is one list — open, not dragged, not saved as a group.
630
+ if (g === RECENT_GROUP) return html`<div key=${g} class="v-group v-recent" role="group" aria-label=${tn("recentOthers")}>
631
+ <div class="v-group-head recent-head"><${Icon} name="clock" size=${14} /><span class="grow">${tn("recentOthers")}</span><span class="count">${names.length}</span></div>
632
+ <ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul></div>`;
467
633
  // #1664: Favorites — always open, not dragged (↑ ↓ in Edit), named in the page's language.
468
634
  if (g === FAV_GROUP) return html`<div key=${g} class="v-group v-fav" role="group" aria-label=${tn("favorites")}>
469
635
  <div class="v-group-head fav-head"><${Icon} name="star" size=${14} cls="fav-mark" /><span class="grow">${tn("favorites")}</span><span class="count">${names.length}</span></div>
@@ -471,14 +637,14 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
471
637
  const folded = nav.collapsed.has(g);
472
638
  return html`<div key=${g} class="v-group">
473
639
  <button type="button" class="v-group-head" aria-expanded=${folded ? "false" : "true"} onClick=${() => toggle(g)}
474
- title=${tn(folded ? "expandGroup" : "collapseGroup", { group: g })} ...${dragProps({ type: "group", name: g })}>
475
- <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${g}</span><span class="count">${names.length}</span></button>
640
+ title=${tn(folded ? "expandGroup" : "collapseGroup", { group: groupLabel(g) })} ...${dragProps({ type: "group", name: g })}>
641
+ <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><${GroupMark} group=${g} /><span class="grow">${groupLabel(g)}</span><span class="count">${names.length}</span></button>
476
642
  ${folded ? null : html`<ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul>`}
477
643
  </div>`;
478
644
  })}
479
645
  ${loaded && shown.active && !shown.groups.length ? html`<p class="side-empty">${tn("filterNone")}</p>` : null}
480
646
  </div>
481
- <div class="v-filter">
647
+ ${guest ? null : html`<div class="v-filter">
482
648
  <label class="v-filter-box"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("filterPh")}</span>
483
649
  <input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck=${false} placeholder=${tn("filterPh")} value=${f.q}
484
650
  onInput=${(e) => setNavFilter({ q: e.target.value })}
@@ -494,7 +660,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
494
660
  </div>
495
661
  <p class="note nav-shown" aria-live="polite">${shown.shown} / ${shown.total}${shown.active ? ` ${tn("filterShown")}` : ""}</p>
496
662
  </div>
497
- </div>
663
+ </div>`}
498
664
  </div>`;
499
665
  }
500
666
 
@@ -16,7 +16,7 @@ import { HeaderTools, VIEW_SCALE, textSizeStore } from "./header-tools.js";
16
16
  import { readStream } from "./read-stream.js";
17
17
  // The usage dialog's metric row lives with the dialog now (usage-dialog.js); re-exported for what imported it here.
18
18
  export { UsageMetric } from "./usage-dialog.js";
19
- import { backendLabel, STATUS_KEYS } from "./instance-nav.js";
19
+ import { backendLabel, modelText, STATUS_KEYS } from "./instance-nav.js";
20
20
  import { viewStore } from "./view-roster-store.js";
21
21
 
22
22
  /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
@@ -27,7 +27,7 @@ import { Dialog } from "./ui-dialog.js";
27
27
  import { Empty, ErrorState, Skeleton } from "./ui-states.js";
28
28
  import { Icon } from "./ui-icons.js";
29
29
  import "./view-strings.js";
30
- import { checkCard, fitPicture, IdentityCard, useCard } from "./identity-card.js";
30
+ import { BannerFocusPreview, checkCard, fitPicture, IdentityCard, useBannerPreview, useCard } from "./identity-card.js";
31
31
  import { confirmDialog } from "./ui-confirm.js";
32
32
  import { openColorDialog } from "./identity-color.js";
33
33
 
@@ -305,7 +305,7 @@ function Card({ it, viewOnly, onEdit }) {
305
305
  <div class="v-avatar">${it.has_avatar ? html`<img ref=${avatar} alt="" />` : html`<span class="v-initial" aria-hidden="true">${initial}</span>`}</div>
306
306
  <div class="v-card-body">
307
307
  <div class="v-card-line"><strong>${it.display_name || it.instance_name}</strong>${it.role ? html`<span class="v-role">${it.role}</span>` : null}</div>
308
- <div class="v-card-meta">${it.instance_name} · ${backendLabel(it.backend)}${it.model ? ` · ${it.model}` : ""}${ctx}</div>
308
+ <div class="v-card-meta">${it.instance_name} · ${backendLabel(it.backend)}${it.model ? ` · ${modelText(it)}` : ""}${ctx}</div>
309
309
  ${open && it.description ? html`<p class="v-card-desc">${it.description}</p>` : null}
310
310
  </div>
311
311
  <button type="button" class="icon-btn" onClick=${toggle} aria-expanded=${open ? "true" : "false"} aria-label=${tn(open ? "collapseProfile" : "expandProfile")} title=${tn(open ? "collapseProfile" : "expandProfile")}>
@@ -332,6 +332,11 @@ function EditProfile({ it, onClose }) {
332
332
  const [removeCard, setRemoveCard] = useState({ portrait: false, banner: false });
333
333
  const [clearCard, setClearCard] = useState(false);
334
334
  const pics = { portrait: useRef(null), banner: useRef(null) };
335
+ // #2086: the banner's focus — this person's (null: follow the agent's, else the centre), and the picture to preview:
336
+ // a newly chosen file, else the banner drawn now.
337
+ // `agent`: the agent's own focus (what "Follow the agent's" shows; else the centre).
338
+ const [focus, setFocus] = useState({ value: 50, mine: null, start: null, agent: null });
339
+ const [bannerPreview, preview] = useBannerPreview();
335
340
  useEffect(() => {
336
341
  let live = true;
337
342
  // The card's fields stay disabled until this read is in (#1889 review: a draft typed meanwhile was overwritten);
@@ -343,6 +348,11 @@ function EditProfile({ it, onClose }) {
343
348
  const resolved = checkCard(it.instance_name, body);
344
349
  const mine = (k) => (resolved && resolved[k] && resolved[k].by === "human" ? resolved[k] : null);
345
350
  setCard({ mine: { tagline: mine("tagline"), bio: mine("bio"), portrait: mine("portrait"), banner: mine("banner") }, limits: body.limits || null });
351
+ const bf = resolved && resolved.banner_focus;
352
+ const agent = Number.isInteger(body.banner_focus_agent) && body.banner_focus_agent >= 0 && body.banner_focus_agent <= 100 ? body.banner_focus_agent : null;
353
+ const mineFocus = bf && bf.by === "human" ? bf.value : null;
354
+ setFocus({ value: mineFocus ?? agent ?? 50, mine: mineFocus, start: mineFocus, agent });
355
+ if (resolved && resolved.banner) preview.fromSrc(resolved.banner.src);
346
356
  setCardText({ tagline: mine("tagline") ? mine("tagline").text : "", bio: mine("bio") ? mine("bio").text : "" });
347
357
  }).catch(() => { if (live && lease.current()) setCard(none); });
348
358
  return () => { live = false; };
@@ -360,6 +370,7 @@ function EditProfile({ it, onClose }) {
360
370
  const clearing = clearCard;
361
371
  const before = card && card.mine ? { tagline: card.mine.tagline ? card.mine.tagline.text : "", bio: card.mine.bio ? card.mine.bio.text : "" } : { tagline: "", bio: "" };
362
372
  if (!clearing) for (const k of ["tagline", "bio"]) if (cardText[k] !== before[k]) draft[`card_${k}`] = cardText[k];
373
+ if (!clearing && focus.mine !== focus.start) draft.card_banner_focus = focus.mine; // #2086
363
374
  const cardPics = {};
364
375
  try {
365
376
  // A picture too large is fitted first (after asking) — before anything is written, so declining writes nothing.
@@ -422,10 +433,19 @@ function EditProfile({ it, onClose }) {
422
433
  <label class="field"><span>${tn("cardTagline")}</span><input value=${cardText.tagline} onInput=${(e) => setCardText({ ...cardText, tagline: e.target.value })} /></label>
423
434
  <label class="field"><span>${tn("cardBio")}</span><textarea rows="4" value=${cardText.bio} onInput=${(e) => setCardText({ ...cardText, bio: e.target.value })}></textarea></label>
424
435
  ${["portrait", "banner"].map((slot) => html`<div key=${slot} class="form">
425
- <label class="field"><span>${tn(slot === "portrait" ? "cardPortrait" : "cardBanner")}</span><input ref=${pics[slot]} type="file" accept="image/png,image/jpeg,image/webp" /></label>
436
+ <label class="field"><span>${tn(slot === "portrait" ? "cardPortrait" : "cardBanner")}</span><input ref=${pics[slot]} type="file" accept="image/png,image/jpeg,image/webp"
437
+ onChange=${slot === "banner" ? (e) => { const fl = e.currentTarget.files && e.currentTarget.files[0]; if (fl) preview.fromFile(fl); } : undefined} /></label>
426
438
  ${card && card.mine && card.mine[slot] ? html`<div class="field-row"><button type="button" class="btn btn-sm" aria-pressed=${removeCard[slot] ? "true" : "false"}
427
439
  onClick=${() => setRemoveCard({ ...removeCard, [slot]: !removeCard[slot] })}>${tn(removeCard[slot] ? "cardKeep" : "cardRemove")}</button></div>` : null}
428
440
  </div>`)}
441
+ ${bannerPreview ? html`<div class="field bf-field">
442
+ <span>${tn("cardBannerFocus")}</span>
443
+ <${BannerFocusPreview} src=${bannerPreview.src} focus=${focus.value} />
444
+ <input type="range" min="0" max="100" step="1" value=${focus.value} aria-label=${tn("cardBannerFocus")}
445
+ onInput=${(e) => { const v = Number(e.currentTarget.value); setFocus({ ...focus, value: v, mine: v }); }} />
446
+ <span class="note">${tn("cardBannerFocusNote")}</span>
447
+ ${focus.mine !== null ? html`<div class="field-row"><button type="button" class="btn btn-sm" onClick=${() => setFocus({ ...focus, value: focus.agent ?? 50, mine: null })}>${tn("cardFocusFollow")}</button></div>` : null}
448
+ </div>` : null}
429
449
  </fieldset>
430
450
  <div class="field-row"><button type="button" class="btn btn-sm" aria-pressed=${clearCard ? "true" : "false"} onClick=${() => setClearCard(!clearCard)}>${tn(clearCard ? "keepCard" : "clearCard")}</button>
431
451
  ${clearCard ? html`<span class="muted">${tn("clearCardNote")}</span>` : null}</div>
@@ -9,7 +9,12 @@
9
9
  * surfaces it is used on, and focus rings and strong borders meet 3:1 (tests/web-tokens-1408.test.ts).
10
10
  *
11
11
  * Inter is bundled (/assets/inter.woff2, SIL OFL 1.1 — see fonts/README.md): the CSP allows fonts from 'self' only,
12
- * and nothing is loaded from another site. Chinese falls back to the system's CJK fonts, in the order below. */
12
+ * and nothing is loaded from another site. Chinese falls back to the system's CJK fonts, in the order below.
13
+ *
14
+ * Emoji (#1918 follow-up, the user's tofu boxes): every stack ends with the platforms' colour emoji fonts and the
15
+ * `emoji` generic (--font-emoji), so an emoji the text fonts lack is drawn by Windows' Segoe UI Emoji, macOS's Apple
16
+ * Color Emoji or Linux's Noto Color Emoji rather than as a box — for the stock stacks and each theme font
17
+ * (app.css: .th-font-system, .th-font-mono). A browser tries the families in order for each character. */
13
18
 
14
19
  @font-face {
15
20
  font-family: "Inter";
@@ -22,8 +27,9 @@
22
27
 
23
28
  :root {
24
29
  /* ── Type ── */
25
- --font-ui: Inter, "PingFang TC", "PingFang SC", "Noto Sans TC", "Noto Sans SC", "Noto Sans CJK TC", "Noto Sans CJK SC", "Microsoft JhengHei", "Microsoft YaHei", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
26
- --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
30
+ --font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", emoji;
31
+ --font-ui: Inter, "PingFang TC", "PingFang SC", "Noto Sans TC", "Noto Sans SC", "Noto Sans CJK TC", "Noto Sans CJK SC", "Microsoft JhengHei", "Microsoft YaHei", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif, var(--font-emoji);
32
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace, var(--font-emoji);
27
33
  --fs-xs: 12px; --lh-xs: 16px;
28
34
  --fs-sm: 13px; --lh-sm: 18px;
29
35
  --fs-md: 15px; --lh-md: 24px; /* message text */
@@ -37,6 +37,7 @@ const P = {
37
37
  type: () => html`<path d="M4 7V5h16v2M9 19h6M12 5v14"/>`,
38
38
  chart: () => html`<path d="M3 20h18M6 16v-4M11 16V8M16 16v-6"/>`,
39
39
  up: () => html`<path d="M12 19V5M5 12l7-7 7 7"/>`,
40
+ sort: () => html`<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>`, // #2179: the sidebar's sort
40
41
  star: () => html`<path d="M12 2.5l2.94 5.96 6.56.95-4.75 4.63 1.12 6.54L12 17.5l-5.87 3.08 1.12-6.54L2.5 9.41l6.56-.95L12 2.5z"/>`,
41
42
  eye: () => html`<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`,
42
43
  check: () => html`<path d="M20 6L9 17l-5-5"/>`,
@@ -4,6 +4,7 @@
4
4
  import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "./app-html.js";
5
5
  import { t } from "./app-i18n.js";
6
6
  import { useLease } from "./app-ctx.js";
7
+ import { guestPage } from "./app-store.js";
7
8
  import { readStream } from "./read-stream.js";
8
9
  import { Dialog } from "./ui-dialog.js";
9
10
  import { ErrorState, Skeleton } from "./ui-states.js";
@@ -66,7 +67,17 @@ export function UsageMetric({ m }) {
66
67
  const sub = [note, expiryText(m.expiresAt)].filter(Boolean).join(" · ");
67
68
  return html`<div class="u-metric"><div class="u-row"><span class="u-label">${label}</span><span class="u-val">${val}</span></div>${sub ? html`<div class="u-sub">${sub}</div>` : null}</div>`;
68
69
  }
69
- /** "Updated 3 min ago" (#1585): how old the numbers are — the panel may show the last snapshot while a fresh one comes. */
70
+ /**
71
+ * #2014: a row's title. A credential profile's row (`profile`; its API `name` is "Claude (<profile>)") shows the
72
+ * provider name on the title line beside the plan badge, and the profile on its own full-width muted line under it;
73
+ * the full name is the tooltip, and the dialog shows the profile unclipped. Any other row: its name.
74
+ */
75
+ export function providerTitle(p) {
76
+ const name = String((p && p.name) || ""), profile = p && typeof p.profile === "string" && p.profile ? p.profile : "";
77
+ const suffix = ` (${profile})`;
78
+ return profile && name.endsWith(suffix) ? { base: name.slice(0, -suffix.length), profile } : { base: name, profile };
79
+ }
80
+
70
81
  /**
71
82
  * #1749: a plan as its badge reads — one short line. "Name [Tier] (N×)" (Claude's "Team Premium (6.25x)", "Max (20x)")
72
83
  * becomes "Name ×N"; anything else (Pro, Codex's and muse's labels) as it is. The full name stays on the badge's
@@ -76,6 +87,7 @@ export function planBadge(plan) {
76
87
  const m = /^(\S+)(?: [^()]+)? \((\d+(?:\.\d+)?)x\)$/i.exec(String(plan || "").trim());
77
88
  return m ? `${m[1]} ×${m[2]}` : String(plan || "");
78
89
  }
90
+ /** "Updated 3 min ago" (#1585): how old the numbers are — the panel may show the last snapshot while a fresh one comes. */
79
91
  export function updatedAgo(fetchedAt, now = Date.now()) {
80
92
  const ms = now - new Date(fetchedAt).getTime();
81
93
  if (!Number.isFinite(ms) || ms < 60_000) return tn("usageUpdatedJustNow");
@@ -134,11 +146,12 @@ export function UsageBody({ owner }) {
134
146
  const providers = ordered(data.providers);
135
147
  content = html`${!providers.length ? html`<p class="note">${tn("usage.empty_active")}</p>` : null}
136
148
  ${providers.map((p, i) => html`<section key=${key(p)} class="u-provider">
137
- <div class="u-head"><strong title=${p.name}>${p.name}</strong>${p.plan ? html`<span class="pill u-plan" title=${p.plan}><span aria-hidden="true">${planBadge(p.plan)}</span><span class="sr-only">${p.plan}</span></span>` : null}
149
+ <div class="u-head"><strong title=${p.name}>${providerTitle(p).base}</strong>${p.plan ? html`<span class="pill u-plan" title=${p.plan}><span aria-hidden="true">${planBadge(p.plan)}</span><span class="sr-only">${p.plan}</span></span>` : null}
138
150
  <span class="u-order">
139
151
  <button type="button" class="icon-btn" disabled=${i === 0} aria-label=${tn("usageMoveUp")} title=${tn("usageMoveUp")} onClick=${() => move(key(p), -1, data.providers)}><${Icon} name="up" size=${14} /></button>
140
152
  <button type="button" class="icon-btn" disabled=${i === providers.length - 1} aria-label=${tn("usageMoveDown")} title=${tn("usageMoveDown")} onClick=${() => move(key(p), 1, data.providers)}><${Icon} name="down" size=${14} /></button>
141
153
  </span></div>
154
+ ${providerTitle(p).profile ? html`<div class="u-profile" title=${p.name}>${providerTitle(p).profile}</div>` : null}
142
155
  ${p.stale ? html`<p class="note">${usageText(p.hint || "", p.hintI18n)}</p>` : null}
143
156
  ${p.status === "error" && !p.transient ? html`<p class="u-err">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
144
157
  : p.status === "error" && p.transient ? html`<p class="note">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
@@ -147,11 +160,18 @@ export function UsageBody({ owner }) {
147
160
  ${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
148
161
  </section>`)}
149
162
  <div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
163
+ ${byAgent() ? html`<a class="btn btn-ghost btn-sm u-by-agent" href="/ui/fleet/usage" title=${tn("usageByAgentTitle")}>${tn("usageByAgent")}</a>` : null}
150
164
  <button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
151
165
  }
152
166
  return content;
153
167
  }
154
168
 
169
+ /**
170
+ * #2122: the plans above are per login; each agent's own tokens and API-equivalent are Fleet → Usage — linked from the
171
+ * app (not View's anonymous reader, not a guest, who has no Fleet).
172
+ */
173
+ const byAgent = () => !guestPage() && typeof location !== "undefined" && location.pathname.startsWith("/ui");
174
+
155
175
  /** `owner`: the navigation that opened it — its reads and the minute's timer live and end with it. */
156
176
  export function UsageDialog({ onClose, owner = "" }) {
157
177
  return html`<${Dialog} title=${tn("usage.title")} onClose=${onClose} wide=${true}><${UsageBody} owner=${`usage-dialog:${owner}`} /></${Dialog}>`;