@songsid/agend 2.1.12 → 2.2.0-alpha.1

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 (358) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/auth/one-time-code.d.ts +19 -0
  4. package/dist/auth/one-time-code.js +49 -0
  5. package/dist/auth/one-time-code.js.map +1 -0
  6. package/dist/auth-api.d.ts +52 -0
  7. package/dist/auth-api.js +346 -0
  8. package/dist/auth-api.js.map +1 -0
  9. package/dist/backend/claude-code.d.ts +4 -0
  10. package/dist/backend/claude-code.js +97 -0
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +59 -30
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-agent.d.ts +66 -0
  15. package/dist/backend/kiro-agent.js +302 -0
  16. package/dist/backend/kiro-agent.js.map +1 -0
  17. package/dist/backend/kiro-identity.d.ts +80 -0
  18. package/dist/backend/kiro-identity.js +537 -0
  19. package/dist/backend/kiro-identity.js.map +1 -0
  20. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  21. package/dist/backend/kiro-v2-store-worker.js +87 -0
  22. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  23. package/dist/backend/kiro-v2-store.d.ts +54 -0
  24. package/dist/backend/kiro-v2-store.js +131 -0
  25. package/dist/backend/kiro-v2-store.js.map +1 -0
  26. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  27. package/dist/backend/kiro-v3-identity.js +22 -0
  28. package/dist/backend/kiro-v3-identity.js.map +1 -1
  29. package/dist/backend/kiro.d.ts +83 -7
  30. package/dist/backend/kiro.js +418 -91
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +45 -3
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/channel/adapters/discord.d.ts +1 -0
  35. package/dist/channel/adapters/discord.js +29 -10
  36. package/dist/channel/adapters/discord.js.map +1 -1
  37. package/dist/channel/adapters/telegram.d.ts +1 -0
  38. package/dist/channel/adapters/telegram.js +13 -1
  39. package/dist/channel/adapters/telegram.js.map +1 -1
  40. package/dist/channel/types.d.ts +9 -1
  41. package/dist/classic-channel-manager.d.ts +18 -10
  42. package/dist/classic-channel-manager.js +45 -18
  43. package/dist/classic-channel-manager.js.map +1 -1
  44. package/dist/cli.js +225 -127
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +5 -3
  47. package/dist/command-table.js +29 -27
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +46 -0
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +12 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +6 -0
  54. package/dist/connection-secrets.js +4 -0
  55. package/dist/connection-secrets.js.map +1 -1
  56. package/dist/daemon.d.ts +89 -4
  57. package/dist/daemon.js +661 -77
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.d.ts +6 -1
  60. package/dist/darwin-memory.js +17 -6
  61. package/dist/darwin-memory.js.map +1 -1
  62. package/dist/delivery-consumed-watch.d.ts +85 -0
  63. package/dist/delivery-consumed-watch.js +244 -0
  64. package/dist/delivery-consumed-watch.js.map +1 -0
  65. package/dist/delivery-outbox.d.ts +61 -0
  66. package/dist/delivery-outbox.js +120 -2
  67. package/dist/delivery-outbox.js.map +1 -1
  68. package/dist/delivery-reconciliation.d.ts +14 -5
  69. package/dist/delivery-reconciliation.js +27 -4
  70. package/dist/delivery-reconciliation.js.map +1 -1
  71. package/dist/delivery-reconciliation.test.js +35 -1
  72. package/dist/delivery-reconciliation.test.js.map +1 -1
  73. package/dist/event-loop-watch.d.ts +28 -0
  74. package/dist/event-loop-watch.js +75 -3
  75. package/dist/event-loop-watch.js.map +1 -1
  76. package/dist/fleet-context.d.ts +22 -2
  77. package/dist/fleet-level-config.d.ts +2 -2
  78. package/dist/fleet-level-config.js +6 -1
  79. package/dist/fleet-level-config.js.map +1 -1
  80. package/dist/fleet-manager.d.ts +293 -12
  81. package/dist/fleet-manager.js +2364 -449
  82. package/dist/fleet-manager.js.map +1 -1
  83. package/dist/host-memory.d.ts +4 -0
  84. package/dist/host-memory.js.map +1 -1
  85. package/dist/instance-config-impact.js +5 -0
  86. package/dist/instance-config-impact.js.map +1 -1
  87. package/dist/instance-lifecycle.d.ts +10 -5
  88. package/dist/instance-lifecycle.js +162 -53
  89. package/dist/instance-lifecycle.js.map +1 -1
  90. package/dist/locale.js +150 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/login-controller.d.ts +2 -0
  93. package/dist/login-controller.js +17 -5
  94. package/dist/login-controller.js.map +1 -1
  95. package/dist/memory-pressure.d.ts +7 -8
  96. package/dist/memory-pressure.js +76 -15
  97. package/dist/memory-pressure.js.map +1 -1
  98. package/dist/monotonic-budget.d.ts +2 -0
  99. package/dist/monotonic-budget.js +28 -0
  100. package/dist/monotonic-budget.js.map +1 -0
  101. package/dist/needs-you-hub.d.ts +114 -0
  102. package/dist/needs-you-hub.js +370 -0
  103. package/dist/needs-you-hub.js.map +1 -0
  104. package/dist/needs-you-live.d.ts +116 -0
  105. package/dist/needs-you-live.js +228 -0
  106. package/dist/needs-you-live.js.map +1 -0
  107. package/dist/needs-you.d.ts +132 -0
  108. package/dist/needs-you.js +164 -0
  109. package/dist/needs-you.js.map +1 -0
  110. package/dist/node-version-guard.d.ts +30 -0
  111. package/dist/node-version-guard.js +50 -0
  112. package/dist/node-version-guard.js.map +1 -0
  113. package/dist/operator-control-paths.d.ts +4 -0
  114. package/dist/operator-control-paths.js +24 -0
  115. package/dist/operator-control-paths.js.map +1 -0
  116. package/dist/outbound-handlers.d.ts +2 -0
  117. package/dist/outbound-handlers.js +9 -2
  118. package/dist/outbound-handlers.js.map +1 -1
  119. package/dist/outbound-schemas.d.ts +5 -5
  120. package/dist/profile-control.js +4 -20
  121. package/dist/profile-control.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +18 -0
  123. package/dist/public-web-gateway.js +141 -0
  124. package/dist/public-web-gateway.js.map +1 -0
  125. package/dist/public-web-link.d.ts +53 -0
  126. package/dist/public-web-link.js +175 -0
  127. package/dist/public-web-link.js.map +1 -0
  128. package/dist/quickstart-api.d.ts +8 -1
  129. package/dist/quickstart-api.js +86 -48
  130. package/dist/quickstart-api.js.map +1 -1
  131. package/dist/quickstart.d.ts +1 -0
  132. package/dist/quickstart.js +6 -2
  133. package/dist/quickstart.js.map +1 -1
  134. package/dist/scheduler/db.d.ts +7 -1
  135. package/dist/scheduler/db.js +38 -0
  136. package/dist/scheduler/db.js.map +1 -1
  137. package/dist/scheduler/index.d.ts +1 -1
  138. package/dist/scheduler/retry-label.d.ts +11 -0
  139. package/dist/scheduler/retry-label.js +24 -0
  140. package/dist/scheduler/retry-label.js.map +1 -0
  141. package/dist/scheduler/scheduler.d.ts +55 -2
  142. package/dist/scheduler/scheduler.js +220 -8
  143. package/dist/scheduler/scheduler.js.map +1 -1
  144. package/dist/scheduler/types.d.ts +22 -0
  145. package/dist/scheduler/types.js.map +1 -1
  146. package/dist/secret-store.d.ts +5 -0
  147. package/dist/secret-store.js +20 -0
  148. package/dist/secret-store.js.map +1 -1
  149. package/dist/service-activation.d.ts +110 -0
  150. package/dist/service-activation.js +302 -0
  151. package/dist/service-activation.js.map +1 -0
  152. package/dist/service-installer.d.ts +7 -0
  153. package/dist/service-installer.js +7 -4
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-restart-selection.d.ts +6 -5
  156. package/dist/service-restart-selection.js +6 -5
  157. package/dist/service-restart-selection.js.map +1 -1
  158. package/dist/settings-api.d.ts +10 -2
  159. package/dist/settings-api.js +175 -72
  160. package/dist/settings-api.js.map +1 -1
  161. package/dist/settings-baseline.d.ts +20 -0
  162. package/dist/settings-baseline.js +104 -0
  163. package/dist/settings-baseline.js.map +1 -0
  164. package/dist/settings-change.d.ts +21 -0
  165. package/dist/settings-change.js +133 -0
  166. package/dist/settings-change.js.map +1 -0
  167. package/dist/settings-confirmation.d.ts +105 -0
  168. package/dist/settings-confirmation.js +232 -0
  169. package/dist/settings-confirmation.js.map +1 -0
  170. package/dist/settings-control.d.ts +41 -0
  171. package/dist/settings-control.js +294 -0
  172. package/dist/settings-control.js.map +1 -0
  173. package/dist/settings-effect.d.ts +24 -0
  174. package/dist/settings-effect.js +169 -0
  175. package/dist/settings-effect.js.map +1 -0
  176. package/dist/settings-http-confirmation.d.ts +29 -0
  177. package/dist/settings-http-confirmation.js +123 -0
  178. package/dist/settings-http-confirmation.js.map +1 -0
  179. package/dist/settings-instance-patch.d.ts +4 -0
  180. package/dist/settings-instance-patch.js +26 -0
  181. package/dist/settings-instance-patch.js.map +1 -0
  182. package/dist/settings-request-capability.d.ts +16 -0
  183. package/dist/settings-request-capability.js +32 -0
  184. package/dist/settings-request-capability.js.map +1 -0
  185. package/dist/settings-transaction.d.ts +65 -0
  186. package/dist/settings-transaction.js +223 -0
  187. package/dist/settings-transaction.js.map +1 -0
  188. package/dist/setup-auth.d.ts +10 -18
  189. package/dist/setup-auth.js +14 -38
  190. package/dist/setup-auth.js.map +1 -1
  191. package/dist/setup-form.d.ts +1 -1
  192. package/dist/setup-form.js +75 -18
  193. package/dist/setup-form.js.map +1 -1
  194. package/dist/setup-guide.d.ts +13 -0
  195. package/dist/setup-guide.js +25 -0
  196. package/dist/setup-guide.js.map +1 -0
  197. package/dist/setup-host.d.ts +16 -0
  198. package/dist/setup-host.js +197 -32
  199. package/dist/setup-host.js.map +1 -1
  200. package/dist/setup-wizard.js +3 -0
  201. package/dist/setup-wizard.js.map +1 -1
  202. package/dist/slash-authz.d.ts +17 -3
  203. package/dist/slash-authz.js +5 -0
  204. package/dist/slash-authz.js.map +1 -1
  205. package/dist/spawn-gate.js +3 -3
  206. package/dist/spawn-gate.js.map +1 -1
  207. package/dist/statusline-watcher.d.ts +5 -0
  208. package/dist/statusline-watcher.js +2 -0
  209. package/dist/statusline-watcher.js.map +1 -1
  210. package/dist/steer-capability.d.ts +6 -0
  211. package/dist/steer-capability.js +8 -0
  212. package/dist/steer-capability.js.map +1 -1
  213. package/dist/sync-work-attribution.d.ts +14 -0
  214. package/dist/sync-work-attribution.js +43 -8
  215. package/dist/sync-work-attribution.js.map +1 -1
  216. package/dist/tips.js +10 -5
  217. package/dist/tips.js.map +1 -1
  218. package/dist/tmux-control.d.ts +35 -5
  219. package/dist/tmux-control.js +291 -57
  220. package/dist/tmux-control.js.map +1 -1
  221. package/dist/tmux-manager.d.ts +20 -6
  222. package/dist/tmux-manager.js +90 -47
  223. package/dist/tmux-manager.js.map +1 -1
  224. package/dist/tmux-read.d.ts +59 -0
  225. package/dist/tmux-read.js +213 -0
  226. package/dist/tmux-read.js.map +1 -0
  227. package/dist/topic-commands.d.ts +21 -10
  228. package/dist/topic-commands.js +191 -58
  229. package/dist/topic-commands.js.map +1 -1
  230. package/dist/transcript-delta-reader.js +2 -1
  231. package/dist/transcript-delta-reader.js.map +1 -1
  232. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  233. package/dist/tunnel/cloudflared-install.js +1 -1
  234. package/dist/tunnel/cloudflared-install.js.map +1 -1
  235. package/dist/tunnel/purpose-lane.d.ts +14 -0
  236. package/dist/tunnel/purpose-lane.js +43 -0
  237. package/dist/tunnel/purpose-lane.js.map +1 -0
  238. package/dist/turn-reply-guard.d.ts +2 -0
  239. package/dist/turn-reply-guard.js +10 -1
  240. package/dist/turn-reply-guard.js.map +1 -1
  241. package/dist/types.d.ts +33 -0
  242. package/dist/ui/app.html +18 -0
  243. package/dist/ui/chat-render.js +478 -0
  244. package/dist/ui/chat-store.js +289 -0
  245. package/dist/ui/chat-strings.js +69 -0
  246. package/dist/ui/chat-thread.js +209 -0
  247. package/dist/ui/chat-tour.js +116 -0
  248. package/dist/ui/fleet-org.js +142 -0
  249. package/dist/ui/panel-chat.js +418 -0
  250. package/dist/ui/panel-fleet.js +413 -0
  251. package/dist/ui/panel-needs.js +202 -0
  252. package/dist/ui/panel-settings.js +750 -0
  253. package/dist/ui/preview.js +266 -0
  254. package/dist/ui/settings-apply.js +239 -0
  255. package/dist/ui/settings-confirm.js +186 -0
  256. package/dist/ui/settings-dialogs.js +546 -0
  257. package/dist/ui/settings-model.js +277 -0
  258. package/dist/ui/settings-strings.js +652 -0
  259. package/dist/ui/settings-wizard.js +140 -0
  260. package/dist/ui/shared/agend-auth.js +93 -0
  261. package/dist/ui/shared/app-ctx.js +80 -0
  262. package/dist/ui/shared/app-html.js +9 -0
  263. package/dist/ui/shared/app-i18n.js +95 -0
  264. package/dist/ui/shared/app-nav.js +73 -0
  265. package/dist/ui/shared/app-needs.js +114 -0
  266. package/dist/ui/shared/app-route.js +73 -0
  267. package/dist/ui/shared/app-session.js +66 -0
  268. package/dist/ui/shared/app-shell.js +344 -0
  269. package/dist/ui/shared/app-store.js +51 -0
  270. package/dist/ui/shared/app-stream.js +201 -0
  271. package/dist/ui/shared/app.css +676 -0
  272. package/dist/ui/shared/app.js +88 -0
  273. package/dist/ui/shared/fonts/OFL.txt +92 -0
  274. package/dist/ui/shared/fonts/README.md +21 -0
  275. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  276. package/dist/ui/shared/htm.module.js +1 -0
  277. package/dist/ui/shared/panel-view.js +655 -0
  278. package/dist/ui/shared/preact-hooks.module.js +2 -0
  279. package/dist/ui/shared/preact.module.js +2 -0
  280. package/dist/ui/shared/signin.js +130 -0
  281. package/dist/ui/shared/theme.js +29 -0
  282. package/dist/ui/shared/tokens.css +153 -0
  283. package/dist/ui/shared/ui-confirm.js +43 -0
  284. package/dist/ui/shared/ui-dialog.js +45 -0
  285. package/dist/ui/shared/ui-icons.js +61 -0
  286. package/dist/ui/shared/ui-menu.js +39 -0
  287. package/dist/ui/shared/ui-states.js +20 -0
  288. package/dist/ui/shared/ui-toast.js +18 -0
  289. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  290. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  291. package/dist/ui/shared/vendor/vendor.json +39 -0
  292. package/dist/ui/shared/view-strings.js +309 -0
  293. package/dist/ui/signin.html +51 -0
  294. package/dist/update-check.d.ts +6 -2
  295. package/dist/update-check.js +12 -3
  296. package/dist/update-check.js.map +1 -1
  297. package/dist/update-install.d.ts +71 -0
  298. package/dist/update-install.js +162 -0
  299. package/dist/update-install.js.map +1 -0
  300. package/dist/upgrade-notices.d.ts +17 -0
  301. package/dist/upgrade-notices.js +93 -0
  302. package/dist/upgrade-notices.js.map +1 -0
  303. package/dist/usage/statusline-usage.d.ts +2 -0
  304. package/dist/usage/statusline-usage.js +14 -10
  305. package/dist/usage/statusline-usage.js.map +1 -1
  306. package/dist/view-api.d.ts +6 -1
  307. package/dist/view-api.js +62 -51
  308. package/dist/view-api.js.map +1 -1
  309. package/dist/web-api.d.ts +71 -14
  310. package/dist/web-api.js +492 -77
  311. package/dist/web-api.js.map +1 -1
  312. package/dist/web-auth.d.ts +71 -27
  313. package/dist/web-auth.js +169 -69
  314. package/dist/web-auth.js.map +1 -1
  315. package/dist/web-body.d.ts +3 -0
  316. package/dist/web-body.js +55 -0
  317. package/dist/web-body.js.map +1 -0
  318. package/dist/web-channel-echo.d.ts +9 -0
  319. package/dist/web-channel-echo.js +55 -0
  320. package/dist/web-channel-echo.js.map +1 -0
  321. package/dist/web-chat-history.d.ts +125 -0
  322. package/dist/web-chat-history.js +184 -0
  323. package/dist/web-chat-history.js.map +1 -0
  324. package/dist/web-continuation.d.ts +7 -0
  325. package/dist/web-continuation.js +22 -0
  326. package/dist/web-continuation.js.map +1 -0
  327. package/dist/web-host-guard.d.ts +34 -0
  328. package/dist/web-host-guard.js +57 -1
  329. package/dist/web-host-guard.js.map +1 -1
  330. package/dist/web-login.d.ts +72 -0
  331. package/dist/web-login.js +132 -0
  332. package/dist/web-login.js.map +1 -0
  333. package/dist/web-org.d.ts +55 -0
  334. package/dist/web-org.js +67 -0
  335. package/dist/web-org.js.map +1 -0
  336. package/dist/web-preview.d.ts +77 -0
  337. package/dist/web-preview.js +240 -0
  338. package/dist/web-preview.js.map +1 -0
  339. package/dist/web-request-context.d.ts +10 -0
  340. package/dist/web-request-context.js +13 -0
  341. package/dist/web-request-context.js.map +1 -0
  342. package/dist/web-session.d.ts +167 -0
  343. package/dist/web-session.js +389 -0
  344. package/dist/web-session.js.map +1 -0
  345. package/dist/web-shell-routes.d.ts +59 -0
  346. package/dist/web-shell-routes.js +96 -0
  347. package/dist/web-shell-routes.js.map +1 -0
  348. package/dist/web-upload.d.ts +183 -0
  349. package/dist/web-upload.js +468 -0
  350. package/dist/web-upload.js.map +1 -0
  351. package/dist/website-tips-renderer.d.ts +2 -2
  352. package/dist/website-tips-renderer.js +3 -3
  353. package/dist/website-tips-renderer.js.map +1 -1
  354. package/package.json +8 -5
  355. package/scripts/preinstall-guard.cjs +36 -0
  356. package/dist/ui/dashboard.html +0 -774
  357. package/dist/ui/settings.html +0 -2165
  358. package/dist/ui/view.html +0 -1139
@@ -0,0 +1,73 @@
1
+ // #1408 §3: the app's routes on the client. The same table as the server's classifier (src/web-shell-routes.ts;
2
+ // a test runs both over one list of cases). Pure: no DOM, no history.
3
+
4
+ export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "config"];
5
+ export const SETTINGS_SECTIONS = ["agents", "bots", "classic", "general", "advanced"];
6
+ const MAX_INSTANCE_NAME = 128;
7
+ const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
8
+
9
+ /** Not empty, no separator, no control character, no traversal — the rule instance paths are held to. */
10
+ export function isSafeInstanceName(name) {
11
+ return typeof name === "string" && name.length > 0 && name.length <= MAX_INSTANCE_NAME
12
+ && !PATH_SEPARATOR_OR_CONTROL.test(name) && name !== "." && !name.includes("..");
13
+ }
14
+
15
+ /**
16
+ * A path → the route it names: { panel: "chat" | "view", instance } | { panel: "fleet", tab } |
17
+ * { panel: "settings", section } | null (not a page of the app: a full load). A malformed name is null too; the
18
+ * server answers it 400.
19
+ */
20
+ export function parseRoute(pathname) {
21
+ if (pathname === "/ui") return { panel: "chat", instance: null };
22
+ if (pathname === "/ui/fleet") return { panel: "fleet", tab: "tasks" };
23
+ if (pathname === "/ui/needs") return { panel: "needs" };
24
+ let m = /^\/ui\/fleet\/([^/]+)$/.exec(pathname);
25
+ if (m) return FLEET_TABS.includes(m[1]) ? { panel: "fleet", tab: m[1] } : null;
26
+ m = /^\/ui\/chat\/([^/]+)$/.exec(pathname);
27
+ if (m) return named("chat", m[1]);
28
+ if (pathname === "/view") return { panel: "view", instance: null };
29
+ m = /^\/view\/([^/]+)$/.exec(pathname);
30
+ if (m) return named("view", m[1]);
31
+ if (pathname === "/settings") return { panel: "settings", section: "agents" };
32
+ m = /^\/settings\/([^/]+)$/.exec(pathname);
33
+ if (m) return SETTINGS_SECTIONS.includes(m[1]) ? { panel: "settings", section: m[1] } : null;
34
+ return null;
35
+ }
36
+ function named(panel, segment) {
37
+ let name = null;
38
+ try { name = decodeURIComponent(segment); } catch { return null; }
39
+ return isSafeInstanceName(name) ? { panel, instance: name } : null;
40
+ }
41
+
42
+ export function chatPath(instance) { return `/ui/chat/${encodeURIComponent(instance)}`; }
43
+ export function viewPath(instance) { return instance ? `/view/${encodeURIComponent(instance)}` : "/view"; }
44
+ export function fleetPath(tab) { return tab && tab !== "tasks" ? `/ui/fleet/${tab}` : "/ui/fleet"; }
45
+ export const NEEDS_PATH = "/ui/needs";
46
+ export function settingsPath(section) { return section && section !== "agents" ? `/settings/${section}` : "/settings"; }
47
+ export function routePath(route) {
48
+ if (!route) return "/ui";
49
+ if (route.panel === "fleet") return fleetPath(route.tab);
50
+ if (route.panel === "settings") return settingsPath(route.section);
51
+ if (route.panel === "needs") return NEEDS_PATH;
52
+ if (route.panel === "view") return viewPath(route.instance);
53
+ return route.instance ? chatPath(route.instance) : "/ui";
54
+ }
55
+ /** One string per route: a change of it is a new navigation (a new lease for the panel, §4). */
56
+ export function routeKey(route) {
57
+ if (!route) return "none";
58
+ if (route.panel === "needs") return "needs:";
59
+ return `${route.panel}:${route.panel === "fleet" ? route.tab : route.panel === "settings" ? route.section : route.instance ?? ""}`;
60
+ }
61
+
62
+ /**
63
+ * #1408 §3: an old deep link, /ui#instance=<name>, as the new path. The fragment never reaches the server, so the app
64
+ * (and the sign-in page, for a signed-out visit) converts it. Exactly that one form; anything else is dropped.
65
+ */
66
+ export function legacyHashTarget(pathname, hash) {
67
+ if (pathname !== "/ui") return null;
68
+ const m = /^#instance=([^&#]*)$/.exec(hash || "");
69
+ if (!m) return null;
70
+ let name;
71
+ try { name = decodeURIComponent(m[1]); } catch { return null; }
72
+ return isSafeInstanceName(name) ? chatPath(name) : null;
73
+ }
@@ -0,0 +1,66 @@
1
+ // The Session menu (#1018; once in the retired shell.js): who is signed in on this device, the other signed-in devices, and signing
2
+ // out. Device labels come from other browsers' User-Agents, so they are only ever rendered as text.
3
+ // It reads /auth/session when the page starts and each time the menu is opened — a person's action, not a timer.
4
+ import { html, useEffect, useRef, useState } from "./app-html.js";
5
+ import { Icon } from "./ui-icons.js";
6
+ import { t, lang } from "./app-i18n.js";
7
+
8
+ async function getJson(url) {
9
+ try {
10
+ const r = await fetch(url, { credentials: "same-origin", cache: "no-store" });
11
+ return r.ok ? await r.json() : null;
12
+ } catch { return null; }
13
+ }
14
+ const clock = (ms) => new Date(ms).toLocaleTimeString(lang() === "zh-TW" ? "zh-TW" : undefined, { hour: "2-digit", minute: "2-digit" });
15
+ const span = (ms) => { const m = Math.max(1, Math.round(ms / 60000)); return m >= 90 ? `${Math.round(m / 60)} h` : `${m} min`; };
16
+ const ago = (ms) => { const m = Math.round((Date.now() - ms) / 60000); return m < 1 ? t("app.justNow") : m < 90 ? t("app.minAgo", m) : t("app.hrAgo", Math.round(m / 60)); };
17
+
18
+ export function SessionMenu() {
19
+ const [me, setMe] = useState(undefined); // undefined: not read yet; null: signed out
20
+ const [sessions, setSessions] = useState(null);
21
+ const [open, setOpen] = useState(false);
22
+ const [error, setError] = useState(false);
23
+ const btn = useRef(null), pop = useRef(null);
24
+ async function refresh() {
25
+ const m = await getJson("/auth/session");
26
+ setMe(m);
27
+ const s = m ? await getJson("/auth/sessions") : null;
28
+ setSessions(s && s.sessions ? s.sessions : null);
29
+ }
30
+ useEffect(() => { refresh(); }, []);
31
+ useEffect(() => {
32
+ if (!open) return undefined;
33
+ const outside = (e) => { if (pop.current && !pop.current.contains(e.target) && !btn.current.contains(e.target)) setOpen(false); };
34
+ document.addEventListener("pointerdown", outside, true);
35
+ return () => document.removeEventListener("pointerdown", outside, true);
36
+ }, [open]);
37
+ async function act(method, url, after) {
38
+ setError(false);
39
+ try {
40
+ const r = await fetch(url, { method, credentials: "same-origin" });
41
+ if (!r.ok) throw new Error(String(r.status));
42
+ after();
43
+ } catch { setError(true); }
44
+ }
45
+ const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); btn.current && btn.current.focus(); } };
46
+ const label = me ? t("app.session") : t("app.signIn");
47
+ const rows = sessions || [];
48
+ return html`<div class="session">
49
+ <button ref=${btn} type="button" class="side-row" aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"}
50
+ onClick=${() => { const next = !open; setOpen(next); if (next) refresh(); }}>
51
+ <span class=${`dot${me ? " ok" : ""}`} aria-hidden="true"></span><span class="grow">${label}</span>${me ? html`<span class="meta">${me.label}</span>` : null}</button>
52
+ ${open ? html`<div ref=${pop} class="pop" role="dialog" aria-label=${t("app.session")} onKeyDown=${onKey}>
53
+ ${!me ? html`<h3>${t("app.signedOut")}</h3><a class="btn" href=${`/signin?next=${encodeURIComponent(location.pathname)}`}>${t("app.signIn")}</a>` : html`
54
+ <h3>${t("app.signedIn")} · ${me.label}</h3>
55
+ <p class="sub">${t("app.expires", clock(me.expiresAt), span(me.idleMs))}</p>
56
+ ${rows.length > 1 ? html`<h3>${t("app.devices")}</h3><ul class="devices">${rows.map(s => html`<li key=${s.handle}>
57
+ <span class="grow"><span class="name">${s.label}${s.current ? ` · ${t("app.thisDevice")}` : ""}</span><span class="when">${t("app.lastActive", ago(s.lastSeen))}</span></span>
58
+ ${s.current ? null : html`<button type="button" class="btn btn-sm danger" onClick=${() => act("DELETE", `/auth/sessions/${encodeURIComponent(s.handle)}`, refresh)}>${t("app.signOut")}</button>`}</li>`)}</ul>` : null}
59
+ <div class="row">
60
+ <button type="button" class="btn" onClick=${() => act("POST", "/auth/logout", () => { location.href = "/signin"; })}><${Icon} name="user" size=${16} />${t("app.signOut")}</button>
61
+ ${rows.length > 1 ? html`<button type="button" class="btn danger" onClick=${() => act("DELETE", "/auth/sessions", () => { location.href = "/signin"; })}>${t("app.signOutAll")}</button>` : null}
62
+ </div>`}
63
+ ${error ? html`<p class="err" role="alert">${t("app.failed")}</p>` : null}
64
+ </div>` : null}
65
+ </div>`;
66
+ }
@@ -0,0 +1,344 @@
1
+ // #1408 §2/§7: the app's frame — the sidebar (a drawer on a phone), the main area with its panel, the bottom tabs on a
2
+ // phone, the connection line, toasts. Panels render their own header through <PanelHeader>, so every panel's title,
3
+ // status and ⋯ actions sit in the same place.
4
+ import { html, useEffect, useState } from "./app-html.js";
5
+ import { Icon } from "./ui-icons.js";
6
+ import { Toasts } from "./ui-toast.js";
7
+ import { ConfirmHost } from "./ui-confirm.js";
8
+ import { t, lang, setLang, onLang } from "./app-i18n.js";
9
+ import { appStore, createStore, useStore } from "./app-store.js";
10
+ import { navStore } from "./app-nav.js";
11
+ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH } from "./app-route.js";
12
+ import { SessionMenu } from "./app-session.js";
13
+ import { ErrorState, Skeleton } from "./ui-states.js";
14
+
15
+ /** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
16
+ export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), footer: [], keyboard: false, dialog: null, side: null });
17
+
18
+ /**
19
+ * The mounted panel's own sidebar section, in place of the instance list (View's roster: its groups, filter and order,
20
+ * #1408 step 2). Returns the function that takes it away; take it through the panel's lease.
21
+ */
22
+ export function setSideSection(Component) {
23
+ const entry = { Component };
24
+ shellStore.set({ side: entry });
25
+ return () => { if (shellStore.get().side === entry) shellStore.set({ side: null }); };
26
+ }
27
+
28
+ /** An app-level dialog (New instance): rendered by the shell until closed, whatever panel is showing. */
29
+ export function showDialog(Component, props = {}) { shellStore.set({ dialog: { Component, props } }); }
30
+ export function closeDialog() { shellStore.set({ dialog: null }); }
31
+
32
+ function readCollapsed() { try { return localStorage.getItem("agend_sidebar") === "hidden"; } catch { return false; } }
33
+ const narrow = () => typeof matchMedia === "function" && matchMedia("(max-width: 899px)").matches;
34
+
35
+ export function openDrawer() { shellStore.set({ drawer: true }); }
36
+ export function closeDrawer() { if (shellStore.get().drawer) shellStore.set({ drawer: false }); }
37
+ export function toggleSidebar() {
38
+ if (narrow()) { shellStore.set(s => ({ ...s, drawer: !s.drawer })); return; }
39
+ const collapsed = !shellStore.get().collapsed;
40
+ shellStore.set({ collapsed });
41
+ try { localStorage.setItem("agend_sidebar", collapsed ? "hidden" : "shown"); } catch { /* this page only */ }
42
+ }
43
+ /** A panel module adds a control to the sidebar footer (the chat adds the preview opt-in and the tour). */
44
+ export function addFooterItem(key, Component) {
45
+ shellStore.set(s => ({ ...s, footer: [...s.footer.filter(x => x.key !== key), { key, Component }] }));
46
+ }
47
+
48
+ // ── Keys ── One document listener for the page. Esc closes what is on top first (the drawer); then the panel's own
49
+ // handlers run — only for the mounted panel, and only when the focus is inside it or on nothing (the body).
50
+ const panelKeys = new Set();
51
+ /** A panel's key handler while it is mounted; returns the off function (take it through the panel's lease). */
52
+ export function onPanelKey(fn) { panelKeys.add(fn); return () => panelKeys.delete(fn); }
53
+ export function handleKey(e) {
54
+ if (e.key === "Escape" && !e.isComposing && shellStore.get().drawer) { e.preventDefault(); closeDrawer(); return; }
55
+ const main = document.getElementById("main");
56
+ const active = document.activeElement;
57
+ if (active && active !== document.body && main && !main.contains(active)) return;
58
+ for (const fn of [...panelKeys]) fn(e);
59
+ }
60
+
61
+ /** document.title for the panel on screen. */
62
+ /**
63
+ * The tab's title: the panel's own, prefixed with "(N) " while N things need you (#1386 §6.1), so a background tab
64
+ * says so. The count follows the store; the panel only names itself.
65
+ */
66
+ let titleBase = "", titleCount = 0;
67
+ function drawTitle() {
68
+ if (typeof document === "undefined") return;
69
+ document.title = `${titleCount ? `(${titleCount}) ` : ""}${titleBase ? `${titleBase} · AgEnD` : "AgEnD"}`;
70
+ }
71
+ export function setTitle(text) { titleBase = text || ""; drawTitle(); }
72
+ appStore.subscribe((s) => {
73
+ const n = Array.isArray(s.needs) ? s.needs.length : 0;
74
+ if (n !== titleCount) { titleCount = n; drawTitle(); }
75
+ });
76
+
77
+ // ── The parts ──
78
+
79
+ function statusLabel(i, exec, awaiting) {
80
+ if (awaiting != null) return t("app.needsYou");
81
+ if (i.status !== "running") return i.status === "crashed" ? t("app.statusCrashed") : t("app.statusStopped");
82
+ if (exec === "working") return t("app.statusWorking");
83
+ if (exec === "stuck") return t("app.statusStuck");
84
+ return t("app.statusIdle");
85
+ }
86
+ export function statusClass(i, exec, awaiting) {
87
+ if (awaiting != null) return "warn";
88
+ if (!i || i.status !== "running") return i && i.status === "crashed" ? "bad" : "off";
89
+ return exec === "working" ? "busy" : exec === "stuck" ? "warn" : "ok";
90
+ }
91
+ export { statusLabel };
92
+
93
+ function tooltip(i) {
94
+ const src = (s) => (s === "instance" || s === "classic" ? "configured" : s === "fleet-default" ? "fleet default" : s);
95
+ const model = i.model ? (i.model_source === "live" || i.model_source === "cli-default" || i.model_source === "unresolved" ? i.model : `${i.model} (${src(i.model_source)})`) : null;
96
+ const effort = i.effort ? `effort:${i.effort}${i.effort_source ? ` (${src(i.effort_source)})` : ""}` : null;
97
+ const ctx = i.context_pct != null ? `ctx:${Math.round(i.context_pct)}%` : null;
98
+ const cost = i.cost > 0 ? `$${i.cost.toFixed(2)}` : null;
99
+ return [i.name, i.backend, model, effort, ctx, cost, i.status].filter(Boolean).join(" · ");
100
+ }
101
+
102
+ function InstanceRow({ i, active, exec, awaiting }) {
103
+ const alias = typeof i.display_name === "string" && i.display_name.trim() && i.display_name.trim() !== i.name ? i.display_name.trim() : "";
104
+ const cls = statusClass(i, exec, awaiting);
105
+ // Two lines: the name (never shortened to make room for a badge, #1408 rough edge 6), then the alias and/or
106
+ // "needs you". Everything else is in the tooltip.
107
+ return html`<li><a class=${`inst${active ? " active" : ""}`} href=${chatPath(i.name)} title=${tooltip(i)} aria-current=${active ? "page" : undefined}
108
+ onClick=${closeDrawer}>
109
+ <span class=${`dot ${cls}`} aria-hidden="true"></span>
110
+ <span class="inst-text"><span class="inst-name">${i.name}</span>
111
+ ${alias || awaiting != null ? html`<span class="inst-sub">${alias ? html`<span class="inst-alias">${alias}</span>` : null}
112
+ ${awaiting != null ? html`<span class="badge-await" title=${awaiting || t("app.approxNote")}>${t("app.needsYou")}</span>` : null}</span>` : null}
113
+ <span class="sr-only">${statusLabel(i, exec, awaiting)}</span></span></a></li>`;
114
+ }
115
+
116
+ /** Sign in, and come back here afterwards (the sign-in page accepts only the app's own pages). */
117
+ export function signInHref() {
118
+ const here = typeof location !== "undefined" ? location.pathname + location.search : "/view";
119
+ return `/signin?next=${encodeURIComponent(here)}`;
120
+ }
121
+
122
+ function Sidebar({ route, onNewInstance, viewOnly }) {
123
+ const app = useStore(appStore);
124
+ const shell = useStore(shellStore);
125
+ const current = route && route.panel === "chat" ? route.instance : null;
126
+ const on = (panel) => !!route && route.panel === panel;
127
+ // A count badge is hidden at 0; its number is also in the link's accessible name.
128
+ const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
129
+ aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t("app.needsCount", count)})` : undefined} onClick=${closeDrawer}>
130
+ <${Icon} name=${icon} /><span class="grow">${label}</span>${count ? html`<span class="nav-count" aria-hidden="true">${count}</span>` : null}</a>`;
131
+ const needsCount = Array.isArray(app.needs) ? app.needs.length : 0;
132
+ // View-only (an anonymous reader, #1408 §3): View and a way to sign in — nothing that needs a session is rendered.
133
+ if (viewOnly) {
134
+ return html`<aside id="sidebar" class="sidebar" aria-label=${t("app.menu")}>
135
+ <div class="side-head">
136
+ <a class="brand" href="/view" onClick=${closeDrawer}>${t("app.brand")}</a>
137
+ <button type="button" class="icon-btn side-collapse" onClick=${toggleSidebar} aria-label=${narrow() ? t("app.closeMenu") : t("app.collapse")} title=${narrow() ? t("app.closeMenu") : t("app.collapse")} aria-controls="sidebar"><${Icon} name="sidebar" /></button>
138
+ </div>
139
+ <nav class="side-nav" aria-label=${t("app.menu")}>${navLink("view", "/view", "view", t("app.view"))}</nav>
140
+ ${shell.side ? html`<${shell.side.Component} />` : html`<div class="side-section"></div>`}
141
+ <div class="side-foot">
142
+ <${Prefs} />
143
+ <a class="side-row" href=${signInHref()}><${Icon} name="user" /><span>${t("app.signIn")}</span></a>
144
+ </div>
145
+ </aside>`;
146
+ }
147
+ return html`<aside id="sidebar" class="sidebar" aria-label=${t("app.menu")}>
148
+ <div class="side-head">
149
+ <a class="brand" href="/ui" onClick=${closeDrawer}>${t("app.brand")}</a>
150
+ <button type="button" class="icon-btn side-collapse" onClick=${toggleSidebar} aria-label=${narrow() ? t("app.closeMenu") : t("app.collapse")} title=${narrow() ? t("app.closeMenu") : t("app.collapse")} aria-controls="sidebar"><${Icon} name="sidebar" /></button>
151
+ <button type="button" class="icon-btn" onClick=${onNewInstance} aria-label=${t("app.newInstance")} title=${t("app.newInstance")}><${Icon} name="edit" /></button>
152
+ </div>
153
+ <nav class="side-nav" aria-label=${t("app.menu")}>
154
+ ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
155
+ ${navLink("fleet", "/ui/fleet", "fleet", t("app.fleet"))}
156
+ ${navLink("view", "/view", "view", t("app.view"))}
157
+ </nav>
158
+ ${shell.side ? html`<${shell.side.Component} />` : html`<div class="side-section" id="instanceList">
159
+ <h2 class="side-label">${t("app.instances")}</h2>
160
+ ${app.instances.length ? html`<ul class="inst-list">${app.instances.map(i => html`<${InstanceRow} key=${i.name} i=${i} active=${i.name === current}
161
+ exec=${app.exec[i.name]} awaiting=${Object.prototype.hasOwnProperty.call(app.awaiting, i.name) ? app.awaiting[i.name] : null} />`)}</ul>`
162
+ : html`<p class="side-empty">${t("app.noInstances")}</p>`}
163
+ </div>`}
164
+ <div class="side-foot">
165
+ ${navLink("settings", settingsPath(), "settings", t("app.settings"))}
166
+ ${shell.footer.map(({ key, Component }) => html`<${Component} key=${key} />`)}
167
+ <${Prefs} />
168
+ <${SessionMenu} />
169
+ </div>
170
+ </aside>`;
171
+ }
172
+
173
+ function Prefs() {
174
+ const [theme, setTheme] = useState(typeof AgendTheme !== "undefined" ? AgendTheme.get() : "system");
175
+ const [l, setL] = useState(lang());
176
+ return html`<div class="prefs">
177
+ <label class="pref"><${Icon} name="sun" size=${16} /><span class="sr-only">${t("app.theme")}</span>
178
+ <select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); }}>
179
+ <option value="system">${t("app.themeSystem")}</option><option value="light">${t("app.themeLight")}</option><option value="dark">${t("app.themeDark")}</option></select></label>
180
+ <label class="pref"><${Icon} name="globe" size=${16} /><span class="sr-only">${t("app.language")}</span>
181
+ <select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); }}>
182
+ <option value="en">English</option><option value="zh-TW">中文</option></select></label>
183
+ </div>`;
184
+ }
185
+
186
+ /** The header every panel shows: ☰ (phone, or a collapsed sidebar), its title and status, then its actions. */
187
+ export function PanelHeader({ title, sub, children, headingRef }) {
188
+ const shell = useStore(shellStore);
189
+ return html`<header class=${`panel-head${shell.collapsed ? " collapsed" : ""}`}>
190
+ <button type="button" id="sbOpen" class="icon-btn sb-open" onClick=${() => (narrow() ? openDrawer() : toggleSidebar())}
191
+ aria-label=${narrow() ? t("app.openMenu") : t("app.expand")} title=${narrow() ? t("app.openMenu") : t("app.expand")} aria-controls="sidebar" aria-expanded=${shell.drawer ? "true" : "false"}><${Icon} name="menu" /></button>
192
+ <div class="panel-title"><h1 ref=${headingRef} tabindex="-1">${title}</h1>${sub ? html`<div class="panel-sub">${sub}</div>` : null}</div>
193
+ <div class="panel-actions">${children}</div>
194
+ </header>`;
195
+ }
196
+
197
+ function BottomTabs({ route, viewOnly }) {
198
+ const app = useStore(appStore);
199
+ let last = null;
200
+ try { last = localStorage.getItem("agend_last_instance"); } catch { /* none */ }
201
+ const chatHref = route && route.panel === "chat" && route.instance ? chatPath(route.instance) : last ? chatPath(last) : "/ui";
202
+ const anyAwaiting = Object.keys(app.awaiting).length;
203
+ const tab = (href, icon, label, active, badge, badgeLabel) => html`<a class=${`tab${active ? " active" : ""}`} href=${href} aria-current=${active ? "page" : undefined}>
204
+ <${Icon} name=${icon} size=${20} /><span>${label}</span>${badge ? html`<span class="tab-badge" aria-label=${badgeLabel || t("app.needsYou")}>${badge}</span>` : null}</a>`;
205
+ let lastView = null;
206
+ try { lastView = localStorage.getItem("agend_last_view"); } catch { /* none */ }
207
+ const viewHref = route && route.panel === "view" && route.instance ? viewPath(route.instance) : viewPath(lastView);
208
+ if (viewOnly) {
209
+ return html`<nav class="tabs" aria-label=${t("app.menu")}>
210
+ ${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
211
+ ${tab(signInHref(), "user", t("app.signIn"), false)}
212
+ </nav>`;
213
+ }
214
+ return html`<nav class="tabs" aria-label=${t("app.menu")}>
215
+ ${tab(chatHref, "chat", t("app.chat"), route && route.panel === "chat", anyAwaiting)}
216
+ ${tab(NEEDS_PATH, "inbox", t("app.needsTab"), route && route.panel === "needs", (app.needs || []).length, t("app.needsCount", (app.needs || []).length))}
217
+ ${tab("/ui/fleet", "fleet", t("app.fleet"), route && route.panel === "fleet")}
218
+ ${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
219
+ ${tab(settingsPath(), "settings", t("app.settings"), route && route.panel === "settings")}
220
+ </nav>`;
221
+ }
222
+
223
+ function ConnectionLine() {
224
+ const { connection, hydration, retryHydration } = useStore(appStore);
225
+ // A late catch-up that is not done: the chat may be missing what was open before it loaded (#1425 review).
226
+ if (hydration === "retrying" || hydration === "failed") {
227
+ return html`<div class="conn" role="status">${hydration === "failed" ? t("app.hydrateFailed") : t("app.hydrating")}
228
+ ${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}</div>`;
229
+ }
230
+ if (connection === "live" || connection === "none") return null;
231
+ const text = connection === "polling" ? t("app.connPolling") : connection === "down" ? t("app.connDown") : null;
232
+ return text ? html`<div class="conn" role="status">${text}</div>` : null;
233
+ }
234
+
235
+ const NOW = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
236
+ function useTick(on, ms = 1000) {
237
+ const [, tick] = useState(0);
238
+ useEffect(() => {
239
+ if (!on) return undefined;
240
+ const h = setInterval(() => tick(n => n + 1), ms);
241
+ return () => clearInterval(h);
242
+ }, [on, ms]);
243
+ }
244
+ const mmss = (ms) => { const s = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
245
+
246
+ /**
247
+ * #1423: Settings writes an admin has to confirm, from whichever panel made them (settings-confirm.js publishes them to
248
+ * appStore.pendingChanges). What it asks, how to confirm it, the countdown, Withdraw — in any panel.
249
+ */
250
+ function PendingChanges() {
251
+ const { pendingChanges } = useStore(appStore);
252
+ const list = pendingChanges || [];
253
+ useTick(list.some(p => p.state === "pending"));
254
+ if (!list.length) return null;
255
+ return html`<div class="pending-stack" role="region" aria-label=${t("app.pendingTitle")}>${list.map(p => html`<section key=${p.id} class=${`pending-card ${p.state}`} role="status">
256
+ <div class="pending-head"><${Icon} name=${p.state === "applied" ? "check" : p.state === "pending" || p.state === "applying" ? "clock" : "alert"} size=${16} />
257
+ <strong>${t(`app.pending_${p.state}`)}</strong>
258
+ ${p.state === "pending" ? html`<span class="pending-left">${t("app.pendingLeft", mmss(p.deadline - NOW()))}</span>` : null}
259
+ ${p.dismiss ? html`<button type="button" class="icon-btn" aria-label=${t("app.close")} title=${t("app.close")} onClick=${p.dismiss}><${Icon} name="close" size=${14} /></button>` : null}</div>
260
+ ${p.label ? html`<div class="pending-label">${p.label}</div>` : null}
261
+ ${Array.isArray(p.summary) && p.summary.length ? html`<ul class="pending-summary">${p.summary.map((line, i) => html`<li key=${i}>${line}</li>`)}</ul>` : null}
262
+ ${p.state === "pending" ? html`<p class="note">${p.confirmation && p.confirmation.kind === "host_cli" ? t("app.pendingHost", `agend settings confirm ${p.id}`) : t("app.pendingChat")}</p>` : null}
263
+ ${p.state !== "pending" && p.state !== "applying" && p.state !== "applied" && p.outcome && p.outcome.message ? html`<p class="note">${p.outcome.message}</p>` : null}
264
+ ${p.withdraw ? html`<div class="pending-actions"><button type="button" class="btn btn-sm" onClick=${p.withdraw}>${t("app.withdraw")}</button></div>` : null}
265
+ </section>`)}</div>`;
266
+ }
267
+
268
+ /** Settings' Apply, followed from another panel: one line, and the way back to it (§5: progress shows in any panel). */
269
+ function OperationLine({ route }) {
270
+ const { settingsOp: op } = useStore(appStore);
271
+ if (!op || (route && route.panel === "settings")) return null;
272
+ const waiting = op.steps.some(s => s.status === "waiting");
273
+ const done = op.steps.filter(s => s.status === "done").length;
274
+ const restart = op.job && op.job.targets && op.job.targets.some(r => r.status === "restart-required");
275
+ const failedRows = op.job && op.job.targets && op.job.targets.some(r => r.status === "failed");
276
+ const text = op.phase === "writing" ? (waiting ? t("app.opWaiting") : t("app.opWriting", done, op.steps.length))
277
+ : op.phase === "posting" || op.phase === "watching" ? t("app.opApplying")
278
+ : op.phase === "failed" ? t("app.opFailed") : restart ? t("app.opRestart") : failedRows ? t("app.opPartial") : t("app.opDone");
279
+ const busy = op.phase === "writing" || op.phase === "posting" || op.phase === "watching";
280
+ return html`<div class=${`conn op-line${busy ? "" : op.phase === "failed" || restart || failedRows ? " bad" : " ok"}`} role="status">
281
+ <span>${text}</span> <a class="btn btn-sm" href=${settingsPath()}>${t("app.openSettings")}</a></div>`;
282
+ }
283
+
284
+ /**
285
+ * A module loader that can be tried again: a failed attempt is forgotten (the next call loads afresh) and `attempt`
286
+ * counts, so a retry can ask for a new URL — a browser may keep a failed module import cached under the old one.
287
+ */
288
+ export function retryable(load) {
289
+ let pending = null, attempt = 0;
290
+ return () => (pending ??= load(attempt++).catch((err) => { pending = null; throw err; }));
291
+ }
292
+
293
+ /** Shows the panel for the route, loading its module the first time; Retry is a real new attempt. */
294
+ function Outlet({ route, seq, panels }) {
295
+ const [attempt, setAttempt] = useState(0);
296
+ const [, force] = useState(0);
297
+ const entry = route ? panels.get(route.panel) : null;
298
+ useEffect(() => {
299
+ if (entry && !entry.Component && !entry.loading) {
300
+ entry.error = null;
301
+ entry.loading = entry.load().then(C => { entry.Component = C; entry.error = null; }, err => { entry.error = err; }).finally(() => { entry.loading = null; force(n => n + 1); });
302
+ force(n => n + 1);
303
+ }
304
+ }, [entry, seq, attempt]);
305
+ if (!route) return html`<div class="panel"><${ErrorState} message=${t("app.loadFailed")} /></div>`;
306
+ if (entry && entry.error && !entry.loading) return html`<div class="panel"><${ErrorState} onRetry=${() => setAttempt(n => n + 1)} /></div>`;
307
+ if (!entry || !entry.Component) return html`<div class="panel"><${Skeleton} lines=${4} /></div>`;
308
+ const C = entry.Component;
309
+ return html`<${C} route=${route} navKey=${`${routeKey(route)}|${seq}|${lang()}`} />`;
310
+ }
311
+
312
+ export function Shell({ panels, onNewInstance, viewOnly = false }) {
313
+ const nav = useStore(navStore);
314
+ const shell = useStore(shellStore);
315
+ const [, relang] = useState(0);
316
+ // A language switch re-renders everything and gives the panel a new lease (its key includes the language).
317
+ useEffect(() => onLang(() => relang(n => n + 1)), []);
318
+ useEffect(() => { closeDrawer(); }, [nav.seq]);
319
+ // The bottom tabs give their room to the keyboard on a phone.
320
+ useEffect(() => {
321
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
322
+ if (!vv) return undefined;
323
+ const onResize = () => shellStore.set({ keyboard: window.innerHeight - vv.height > 150 });
324
+ vv.addEventListener("resize", onResize);
325
+ return () => vv.removeEventListener("resize", onResize);
326
+ }, []);
327
+ const cls = ["shell", shell.collapsed ? "sb-collapsed" : "", shell.drawer ? "sb-open" : "", shell.keyboard ? "kb-open" : ""].filter(Boolean).join(" ");
328
+ return html`<div class=${cls}>
329
+ <a class="skip" href="#main">${t("app.skip")}</a>
330
+ <${Sidebar} route=${nav.route} onNewInstance=${onNewInstance} viewOnly=${viewOnly} />
331
+ <div class="scrim" onClick=${closeDrawer} aria-hidden="true"></div>
332
+ <main id="main" class="main" tabindex="-1" inert=${shell.drawer && narrow() ? true : undefined}>
333
+ <${ConnectionLine} />
334
+ ${viewOnly ? null : html`<${OperationLine} route=${nav.route} />`}
335
+ <${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
336
+ </main>
337
+ <${BottomTabs} route=${nav.route} viewOnly=${viewOnly} />
338
+ ${shell.dialog ? html`<${shell.dialog.Component} ...${shell.dialog.props} onClose=${closeDialog} />` : null}
339
+ ${viewOnly ? null : html`<${PendingChanges} />`}
340
+ <${ConfirmHost} />
341
+ <${Toasts} />
342
+ </div>`;
343
+ }
344
+
@@ -0,0 +1,51 @@
1
+ // #1408 §5: the app-wide state every panel and the sidebar read — the instances and what the stream says about them.
2
+ // It lives as long as the page; panels come and go. A tiny observable: subscribe(fn) → unsubscribe; a change notifies
3
+ // once per update. Chat's own state (messages, drafts, files…) is the chat store (chat-store.js), page-lived as well.
4
+ import { useEffect, useState } from "./app-html.js";
5
+
6
+ export function createStore(initial) {
7
+ let state = initial;
8
+ const subs = new Set();
9
+ return {
10
+ get: () => state,
11
+ set(patch) {
12
+ const next = typeof patch === "function" ? patch(state) : { ...state, ...patch };
13
+ if (next === state) return;
14
+ state = next;
15
+ for (const fn of [...subs]) fn(state);
16
+ },
17
+ subscribe(fn) { subs.add(fn); return () => subs.delete(fn); },
18
+ };
19
+ }
20
+
21
+ /** The app's store. `ready`: a status frame has arrived (before it, an unknown instance may just not be listed yet).
22
+ * `exec`: each instance's raw execution state (working / idle / stuck / null); `awaiting`: what it
23
+ * waits on at its terminal (text, "" when unknown), absent when it waits on nothing. `hydration`: a late catch-up
24
+ * (app-stream.js catchUp) — "catching" / "retrying" / "failed" / "ok"; `retryHydration` asks for it again. */
25
+ export const appStore = createStore({ ready: false, instances: [], uptime: 0, exec: {}, awaiting: {}, connection: "connecting", needs: [],
26
+ hydration: "none", retryHydration: null });
27
+
28
+ /** Fold one status frame (stream or poll) into the store. */
29
+ export function applyStatus(d) {
30
+ if (!d || !Array.isArray(d.instances)) return;
31
+ const exec = {}, awaiting = {};
32
+ for (const i of d.instances) {
33
+ // The raw execution state, as the "activity" events carry it; `state` may be the presentation "awaiting_input".
34
+ exec[i.name] = (i.execution_state !== undefined ? i.execution_state : i.state) ?? null;
35
+ if (i.state === "awaiting_input") awaiting[i.name] = typeof i.interaction_summary === "string" ? i.interaction_summary : "";
36
+ }
37
+ appStore.set({ ready: true, instances: d.instances, uptime: d.uptime ?? 0, exec, awaiting });
38
+ }
39
+
40
+ /** One "activity" event: an instance started or stopped working. */
41
+ export function applyActivity(d) {
42
+ if (!d || typeof d.instance !== "string") return;
43
+ appStore.set(s => ({ ...s, exec: { ...s.exec, [d.instance]: d.state ?? null } }));
44
+ }
45
+
46
+ /** Re-render on every store change; returns the current state. */
47
+ export function useStore(store = appStore) {
48
+ const [state, setState] = useState(store.get());
49
+ useEffect(() => { setState(store.get()); return store.subscribe(setState); }, [store]);
50
+ return state;
51
+ }