@app-vitals/agent-admin 0.0.1 → 0.1.0

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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,3489 @@
1
+ /**
2
+ * agent/src/admin-ui-pages.ts
3
+ * Pure HTML rendering functions for admin UI pages.
4
+ * No Hono dependencies — pure string → string functions.
5
+ *
6
+ * Follows the same inline HTML template string pattern as
7
+ * metrics/src/dashboard/dashboard-page.ts.
8
+ *
9
+ * Platform pages only: the coding agent's task/PR/session/queue pages live in
10
+ * coding/ (AEP-3.2) and are mounted by the coding AdminExtension.
11
+ */
12
+
13
+ import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
14
+ import { renderAdminPage } from "./admin-ui-layout.ts";
15
+ import {
16
+ BREAKPOINT_MOBILE_MAX,
17
+ escapeHtml,
18
+ renderAdminToolbar,
19
+ } from "./admin-ui-styles.ts";
20
+ import type { ManualStep } from "./agent-deletion-checklist.ts";
21
+ import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
22
+ import { isAudioFilename } from "./attachment-validation.ts";
23
+ import { parseChatMarkers } from "./chat-markers.ts";
24
+ import type {
25
+ ChatMessage,
26
+ ChatThread,
27
+ MessageTokens,
28
+ ThreadStats,
29
+ } from "./http-chat-client.ts";
30
+ import { renderPushToggle } from "./push-toggle.ts";
31
+
32
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Returns a human-friendly relative timestamp.
36
+ * Examples: "just now", "5 minutes ago", "2 hours ago", "3 days ago", "1 week ago"
37
+ *
38
+ * @param date - the timestamp to describe
39
+ * @param now - the reference "current" time (defaults to wall clock; override in tests for determinism)
40
+ */
41
+ export function relativeTime(date: Date, now: Date = new Date()): string {
42
+ const diffMs = now.getTime() - date.getTime();
43
+ const diffSec = Math.floor(diffMs / 1000);
44
+ const diffMin = Math.floor(diffSec / 60);
45
+ const diffHour = Math.floor(diffMin / 60);
46
+ const diffDay = Math.floor(diffHour / 24);
47
+ const diffWeek = Math.floor(diffDay / 7);
48
+
49
+ if (diffSec < 60) return "just now";
50
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`;
51
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`;
52
+ if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`;
53
+ return `${diffWeek} week${diffWeek === 1 ? "" : "s"} ago`;
54
+ }
55
+
56
+ // ─── Types ────────────────────────────────────────────────────────────────────
57
+
58
+ export interface AgentListItem {
59
+ id: string;
60
+ name: string;
61
+ slackId: string | null;
62
+ createdAt: Date;
63
+ selfHosted?: boolean;
64
+ accountId?: string | null;
65
+ }
66
+
67
+ export interface AgentDetail {
68
+ id: string;
69
+ name: string;
70
+ slackId: string | null;
71
+ selfHosted: boolean;
72
+ createdAt: Date;
73
+ updatedAt: Date;
74
+ repos: string[];
75
+ authorAllowlist: string[];
76
+ patchAuthorAllowlist: string[];
77
+ /**
78
+ * When true, only AgentMember emails may message this agent over Slack.
79
+ * Rendered as a checkbox on both the create and edit forms.
80
+ */
81
+ restrictSlackToMembers: boolean;
82
+ /** The agent's type name (e.g. "coding"), resolved at creation time. */
83
+ typeName: string;
84
+ /**
85
+ * Required env keys declared by the agent's type manifest with no
86
+ * corresponding AgentEnv row yet — key names only, never values. Always
87
+ * present; purely informational (ATS-4.2), see renderAgentDetailPage's Env
88
+ * Vars card.
89
+ */
90
+ missingRequiredEnv: string[];
91
+ /**
92
+ * APM-1.3: the six real agent-policy fields (APM-1.1 DB columns), surfaced
93
+ * in the "Agent Policy" card. Callers resolve
94
+ * Prisma-column defaults (`?? true`/`?? false`/`?? 75`/`?? 5`/`?? 14`)
95
+ * before building this literal — see admin-ui.ts's two `agentDetail:
96
+ * AgentDetail = {...}` build sites.
97
+ */
98
+ autoPostReviews: boolean;
99
+ allowSelfReview: boolean;
100
+ minConfidence: number;
101
+ maxFindings: number;
102
+ cleanupMergedWorktrees: boolean;
103
+ cleanupAfterDays: number;
104
+ /**
105
+ * PAI-2.5: owner and Anthropic identity, shown in the "Owner & Anthropic
106
+ * identity" card. Optional so literal builds that predate the fields stay
107
+ * valid; null or absent renders an em dash placeholder.
108
+ */
109
+ ownerEmail?: string | null;
110
+ anthropicIdentityName?: string | null;
111
+ anthropicServiceAccountId?: string | null;
112
+ anthropicFederationRuleId?: string | null;
113
+ }
114
+
115
+ export interface CronJobItem {
116
+ id: string;
117
+ schedule: string;
118
+ prompt: string;
119
+ channel: string | null;
120
+ user: string | null;
121
+ enabled: boolean;
122
+ name: string | null;
123
+ system: boolean;
124
+ parentCronId: string | null;
125
+ preCheck?: string | null;
126
+ lastRun?: {
127
+ startedAt: Date;
128
+ completedAt: Date | null;
129
+ skipped: boolean;
130
+ outcome: string | null;
131
+ } | null;
132
+ runCountToday?: number;
133
+ createdAt: Date;
134
+ }
135
+
136
+ // Inline CSS for cron-run outcome badges, keyed by outcome string.
137
+ // Shared by the cron rows on the agent detail page and the cron runs page.
138
+ const CRON_OUTCOME_STYLE: Record<string, string> = {
139
+ posted: "background:#22c55e;color:white",
140
+ dm: "background:#3b82f6;color:white",
141
+ silent: "background:#9ca3af;color:white",
142
+ skipped: "background:#f59e0b;color:white",
143
+ error: "background:#ef4444;color:white",
144
+ };
145
+ const CRON_OUTCOME_STYLE_DEFAULT = "background:#9ca3af;color:white";
146
+
147
+ /** Resolve the badge style for a cron outcome, falling back to a neutral gray. */
148
+ export function cronOutcomeStyle(outcome: string | null | undefined): string {
149
+ return CRON_OUTCOME_STYLE[outcome ?? ""] ?? CRON_OUTCOME_STYLE_DEFAULT;
150
+ }
151
+
152
+ /** Label for a cron run's outcome badge — "skipped" takes priority over outcome. */
153
+ export function cronRunOutcomeLabel(run: {
154
+ skipped: boolean;
155
+ outcome: string | null;
156
+ }): string {
157
+ return run.skipped ? "skipped" : (run.outcome ?? "unknown");
158
+ }
159
+
160
+ export interface ToolItem {
161
+ id: string;
162
+ pattern: string;
163
+ enabled: boolean;
164
+ createdAt: Date;
165
+ }
166
+
167
+ export interface TokenItem {
168
+ id: string;
169
+ label: string | null;
170
+ createdAt: Date;
171
+ revokedAt: Date | null;
172
+ }
173
+
174
+ export interface PluginItem {
175
+ id: string;
176
+ name: string;
177
+ version: string | null;
178
+ enabled: boolean;
179
+ createdAt: Date;
180
+ }
181
+
182
+ export interface MemberItem {
183
+ id: string;
184
+ email: string;
185
+ createdAt: Date;
186
+ }
187
+
188
+ // ─── Inline markdown renderer ─────────────────────────────────────────────────
189
+
190
+ /**
191
+ * Render a markdown string to safe HTML.
192
+ * HTML is escaped FIRST to prevent XSS, then markdown patterns are applied
193
+ * to generate a known-safe set of HTML tags.
194
+ */
195
+ export function renderMarkdown(text: string): string {
196
+ // Step 1: escape all HTML entities so raw user input can't inject tags
197
+ let out = escapeHtml(text);
198
+
199
+ // Step 2: extract code blocks into placeholder tokens before line processing
200
+ // so that interior lines of a fenced block are never handed to the line loop.
201
+ const codeBlocks: string[] = [];
202
+ // Use Unicode Private Use Area sentinels — never appear in HTML-escaped markdown,
203
+ // and are not control characters (biome noControlCharactersInRegex safe).
204
+ const PLACEHOLDER_PREFIX = "CODE_BLOCK_";
205
+ const PLACEHOLDER_SUFFIX = "";
206
+ const placeholder = (n: number) =>
207
+ `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`;
208
+ const PLACEHOLDER_RE = /^CODE_BLOCK_(\d+)$/;
209
+
210
+ // Multi-line fenced blocks: ```\n...\n```
211
+ out = out.replace(/```[\r\n]([\s\S]*?)[\r\n]```/g, (_m, code) => {
212
+ const idx = codeBlocks.length;
213
+ codeBlocks.push(`<pre><code>${code}</code></pre>`);
214
+ return placeholder(idx);
215
+ });
216
+ // Same-line fenced blocks: ```code```
217
+ out = out.replace(/```([^`\n]+)```/g, (_m, code) => {
218
+ const idx = codeBlocks.length;
219
+ codeBlocks.push(`<pre><code>${code}</code></pre>`);
220
+ return placeholder(idx);
221
+ });
222
+
223
+ // Step 3: process line-by-line for block-level elements
224
+ const lines = out.split("\n");
225
+ const result: string[] = [];
226
+ let inUl = false;
227
+ let inOl = false;
228
+
229
+ const closeList = () => {
230
+ if (inUl) {
231
+ result.push("</ul>");
232
+ inUl = false;
233
+ }
234
+ if (inOl) {
235
+ result.push("</ol>");
236
+ inOl = false;
237
+ }
238
+ };
239
+
240
+ for (const line of lines) {
241
+ // Placeholder lines are pre-rendered code blocks — emit as-is
242
+ const placeholderMatch = PLACEHOLDER_RE.exec(line);
243
+ if (placeholderMatch) {
244
+ closeList();
245
+ result.push(codeBlocks[Number.parseInt(placeholderMatch[1], 10)]);
246
+ continue;
247
+ }
248
+
249
+ // Headings
250
+ const h3 = line.match(/^### (.+)$/);
251
+ const h2 = line.match(/^## (.+)$/);
252
+ const h1 = line.match(/^# (.+)$/);
253
+
254
+ if (h3) {
255
+ closeList();
256
+ result.push(`<h3>${applyInline(h3[1])}</h3>`);
257
+ } else if (h2) {
258
+ closeList();
259
+ result.push(`<h2>${applyInline(h2[1])}</h2>`);
260
+ } else if (h1) {
261
+ closeList();
262
+ result.push(`<h1>${applyInline(h1[1])}</h1>`);
263
+ } else if (/^[-*] /.test(line)) {
264
+ // Unordered list item
265
+ if (inOl) {
266
+ closeList();
267
+ }
268
+ if (!inUl) {
269
+ result.push("<ul>");
270
+ inUl = true;
271
+ }
272
+ result.push(`<li>${applyInline(line.replace(/^[-*] /, ""))}</li>`);
273
+ } else if (/^\d+\. /.test(line)) {
274
+ // Ordered list item
275
+ if (inUl) {
276
+ closeList();
277
+ }
278
+ if (!inOl) {
279
+ result.push("<ol>");
280
+ inOl = true;
281
+ }
282
+ result.push(`<li>${applyInline(line.replace(/^\d+\. /, ""))}</li>`);
283
+ } else if (line.trim() === "") {
284
+ closeList();
285
+ result.push("");
286
+ } else {
287
+ closeList();
288
+ result.push(applyInline(line));
289
+ }
290
+ }
291
+ closeList();
292
+
293
+ return result.join("\n");
294
+ }
295
+
296
+ /** Apply inline markdown transforms (bold, inline code) to an already-escaped string. */
297
+ function applyInline(s: string): string {
298
+ // Inline code: `code` — must come before bold to avoid double-processing
299
+ const withCode = s.replace(
300
+ /`([^`]+)`/g,
301
+ (_m, code) => `<code>${code}</code>`,
302
+ );
303
+ // Bold: **text**
304
+ return withCode.replace(
305
+ /\*\*([^*]+)\*\*/g,
306
+ (_m, text) => `<strong>${text}</strong>`,
307
+ );
308
+ }
309
+
310
+ // ─── Login page ───────────────────────────────────────────────────────────────
311
+
312
+ export function renderLoginPage(opts?: {
313
+ error?: string;
314
+ returnTo?: string;
315
+ googleEnabled?: boolean;
316
+ oktaEnabled?: boolean;
317
+ }): string {
318
+ const googleEnabled = opts?.googleEnabled ?? true;
319
+ const errorHtml = opts?.error
320
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
321
+ : "";
322
+
323
+ const googleHref = opts?.returnTo
324
+ ? `/admin/auth/google?returnTo=${encodeURIComponent(opts.returnTo)}`
325
+ : "/admin/auth/google";
326
+
327
+ const oktaHref = opts?.returnTo
328
+ ? `/admin/auth/okta?returnTo=${encodeURIComponent(opts.returnTo)}`
329
+ : "/admin/auth/okta";
330
+
331
+ const googleButtonHtml = googleEnabled
332
+ ? `<a href="${googleHref}" class="btn btn-primary" style="width:100%;justify-content:center;text-decoration:none">Sign in with Google</a>`
333
+ : "";
334
+
335
+ const oktaButtonHtml = opts?.oktaEnabled
336
+ ? `<a href="${oktaHref}" class="btn btn-primary" style="width:100%;justify-content:center;text-decoration:none">Sign in with Okta</a>`
337
+ : "";
338
+
339
+ const noProviderHtml =
340
+ !googleEnabled && !opts?.oktaEnabled
341
+ ? `<div class="alert alert-error">No sign-in provider is configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or OKTA_*.</div>`
342
+ : "";
343
+
344
+ return renderAdminPage({
345
+ title: "Admin Login — Shipwright",
346
+ body: `<div class="login-wrapper">
347
+ <div class="login-card">
348
+ <h1 class="login-title">Shipwright Admin</h1>
349
+ <p class="login-subtitle">Sign in to manage your agents.</p>
350
+ ${errorHtml}
351
+ ${noProviderHtml}
352
+ ${googleButtonHtml}
353
+ ${oktaButtonHtml}
354
+ </div>
355
+ </div>`,
356
+ });
357
+ }
358
+
359
+ // ─── Agents list page ─────────────────────────────────────────────────────────
360
+
361
+ export function renderAgentsPage(
362
+ agents: AgentListItem[],
363
+ userName: string,
364
+ isAdmin: boolean,
365
+ timezone: string,
366
+ opts?: {
367
+ successMsg?: string;
368
+ manualSteps?: ManualStep[];
369
+ /** Pre-rendered zero-quota notice + disabled button (replaces the CTA). */
370
+ zeroQuotaNotice?: string;
371
+ /**
372
+ * accountId -> name. Admin-only (SSP-5.2): when set AND isAdmin, an
373
+ * Account column is rendered. Ignored for non-admins.
374
+ */
375
+ accountNames?: Map<string, string>;
376
+ /**
377
+ * Render the Queue & Activity button (AEP-3.6). Only the coding extension
378
+ * mounts /admin/agents/:id/queue-activity; absent/false omits the link.
379
+ */
380
+ queueActivityLinks?: boolean;
381
+ },
382
+ ): string {
383
+ const showAccount = isAdmin && opts?.accountNames !== undefined;
384
+ const accountCell = (a: AgentListItem): string => {
385
+ if (!showAccount) return "";
386
+ const name = a.accountId ? opts?.accountNames?.get(a.accountId) : undefined;
387
+ return `<td>${a.accountId && name ? `<a href="/admin/accounts/${escapeHtml(a.accountId)}">${escapeHtml(name)}</a>` : '<span style="color:#9ca3af">—</span>'}</td>`;
388
+ };
389
+ const successHtml = opts?.successMsg
390
+ ? `<div class="alert alert-success">${escapeHtml(opts.successMsg)}</div>`
391
+ : "";
392
+
393
+ const manualStepsHtml =
394
+ opts?.manualSteps && opts.manualSteps.length > 0
395
+ ? `<div class="alert alert-warning" id="manual-steps-panel" style="position:relative;padding-right:32px">
396
+ <button type="button" onclick="document.getElementById('manual-steps-panel').style.display='none'"
397
+ style="position:absolute;top:8px;right:10px;background:none;border:none;cursor:pointer;font-size:14px;color:inherit" aria-label="Dismiss">×</button>
398
+ <strong>Manual cleanup required:</strong>
399
+ <ul style="margin:8px 0 0 20px">
400
+ ${opts.manualSteps.map((s) => `<li>${escapeHtml(s.message)}</li>`).join("\n ")}
401
+ </ul>
402
+ </div>`
403
+ : "";
404
+
405
+ const rows =
406
+ agents.length === 0
407
+ ? `<tr><td colspan="${showAccount ? 5 : 4}" class="empty-state">${isAdmin ? 'No agents yet. <a href="/admin/agents/new">Create one →</a>' : "No agents."}</td></tr>`
408
+ : agents
409
+ .map(
410
+ (a) => `<tr>
411
+ <td><a href="/admin/agents/${escapeHtml(a.id)}" class="agent-link">${escapeHtml(a.name)}</a></td>
412
+ <td class="mono">${a.slackId ? escapeHtml(a.slackId) : '<span style="color:#9ca3af">—</span>'}</td>
413
+ ${accountCell(a)}
414
+ <td>${escapeHtml(new Date(a.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
415
+ <td>
416
+ <a href="/admin/agents/${escapeHtml(a.id)}" class="btn btn-secondary" style="font-size:12px;padding:4px 10px">Manage</a>${
417
+ opts?.queueActivityLinks
418
+ ? `
419
+ <a href="/admin/agents/${escapeHtml(a.id)}/queue-activity" class="btn btn-secondary" style="font-size:12px;padding:4px 10px;margin-left:4px">Queue &amp; Activity</a>`
420
+ : ""
421
+ }
422
+ </td>
423
+ </tr>`,
424
+ )
425
+ .join("\n");
426
+
427
+ // Single CTA to create an agent — the /admin/agents/new page handles agent
428
+ // creation and provides inline options to connect Slack/GitHub if desired.
429
+ const createAgentButtons = opts?.zeroQuotaNotice
430
+ ? opts.zeroQuotaNotice
431
+ : isAdmin
432
+ ? `<a href="/admin/agents/new" class="btn btn-primary">+ New agent</a>`
433
+ : "";
434
+
435
+ return renderAdminPage({
436
+ title: "Agents — Shipwright Admin",
437
+ body: `${renderAdminToolbar(userName, "/admin/agents")}
438
+ <div class="vos-page">
439
+ <div class="page-header">
440
+ <h1 class="page-title">Agents</h1>
441
+ ${createAgentButtons}
442
+ </div>
443
+ ${successHtml}
444
+ ${manualStepsHtml}
445
+ <div class="card">
446
+ <table class="data-table">
447
+ <thead>
448
+ <tr>
449
+ <th>Name</th>
450
+ <th>Slack ID</th>
451
+ ${showAccount ? "<th>Account</th>" : ""}
452
+ <th>Created</th>
453
+ <th></th>
454
+ </tr>
455
+ </thead>
456
+ <tbody>
457
+ ${rows}
458
+ </tbody>
459
+ </table>
460
+ </div>
461
+ </div>`,
462
+ });
463
+ }
464
+
465
+ // ─── New agent page ───────────────────────────────────────────────────────────
466
+
467
+ // The three Slack/GitHub sections (restrict-slack-group, slack-section,
468
+ // github-section) are meaningless for self-hosted agents — self-hosted uses
469
+ // local git config for GitHub auth, not admin-managed provisioning — so they
470
+ // are shown only when "Provisioned in-cluster" is selected. `display` is
471
+ // either "" (visible) or "none" (hidden).
472
+ function runtimeSectionsOnchange(display: "" | "none"): string {
473
+ const ids = ["restrict-slack-group", "slack-section", "github-section"];
474
+ return ids
475
+ .map((id) => `document.getElementById('${id}').style.display='${display}'`)
476
+ .join(";");
477
+ }
478
+
479
+ export function renderNewLocalAgentPage(
480
+ userName: string,
481
+ types: SpecialtyOption[],
482
+ opts?: {
483
+ error?: string;
484
+ canProvision?: boolean;
485
+ /** WIF-2.1: hide the Claude credential fields (federation supplies auth). */
486
+ anthropicFederationEnabled?: boolean;
487
+ /**
488
+ * Set for self-serve account users (SSP-4.2): shows the account's
489
+ * remaining agent quota and the prerequisites, and pins the type to
490
+ * "coding" (the server forces it regardless).
491
+ */
492
+ selfServe?: { remaining: number; max: number; contactEmail: string };
493
+ },
494
+ ): string {
495
+ const error = opts?.error;
496
+ const selfServe = opts?.selfServe;
497
+ // WIF-2.1: with Anthropic federation enabled the server rejects these
498
+ // credentials for in-cluster agents, so don't offer the fields at all.
499
+ const claudeCredentialFields = opts?.anthropicFederationEnabled
500
+ ? ""
501
+ : ` <div class="form-group">
502
+ <label class="form-label" for="claudeCodeOauthToken">Claude Code OAuth Token (optional)</label>
503
+ <input
504
+ id="claudeCodeOauthToken"
505
+ name="claudeCodeOauthToken"
506
+ type="password"
507
+ class="form-input"
508
+ placeholder="sk-ant-oat01-..."
509
+ />
510
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">
511
+ Stored as the agent's <span class="mono">CLAUDE_CODE_OAUTH_TOKEN</span>. Required for the agent
512
+ to run — set it here or from the agent detail page.
513
+ </p>
514
+ </div>
515
+ <div class="form-group">
516
+ <label class="form-label" for="anthropicApiKey">Anthropic API Key (optional)</label>
517
+ <input
518
+ id="anthropicApiKey"
519
+ name="anthropicApiKey"
520
+ type="password"
521
+ class="form-input"
522
+ placeholder="sk-ant-..."
523
+ />
524
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">
525
+ Stored as the agent's <span class="mono">ANTHROPIC_API_KEY</span>.
526
+ </p>
527
+ </div>`;
528
+ // Whether the admin service can actually create cluster resources. When it
529
+ // can't (NoopAgentProvisioner — SHIPWRIGHT_K8S_PROVISIONING unset), offering
530
+ // "in-cluster" would silently produce an agent row with no pod, so the option
531
+ // is rendered disabled and self-hosted is preselected.
532
+ const canProvision = opts?.canProvision ?? false;
533
+ // Slack/GitHub sections are only relevant to in-cluster (admin-provisioned)
534
+ // agents — self-hosted agents use local git config for GitHub auth. Their
535
+ // initial visibility must match whichever runtime radio is preselected
536
+ // above (canProvision===true → in-cluster preselected → visible).
537
+ const runtimeSectionsDisplay: "" | "none" = canProvision ? "" : "none";
538
+ const errorHtml = error
539
+ ? `<div class="alert alert-error">${escapeHtml(error)}</div>`
540
+ : "";
541
+ const typeOptions = types
542
+ .map(
543
+ (t) =>
544
+ `<option value="${escapeHtml(t.name)}">${escapeHtml(t.displayName)}</option>`,
545
+ )
546
+ .join("\n");
547
+ const selfServeHtml = selfServe
548
+ ? `<div class="card" style="margin-bottom:16px">
549
+ <p style="font-size:14px;font-weight:600;margin-bottom:8px">
550
+ Agent quota: ${selfServe.remaining} of ${selfServe.max} remaining
551
+ </p>
552
+ ${
553
+ selfServe.remaining <= 0
554
+ ? `<p style="font-size:13px;color:#b91c1c;margin-bottom:8px">
555
+ This account has reached its agent limit. Delete an agent to free a slot,
556
+ or contact <a href="mailto:${escapeHtml(selfServe.contactEmail)}">${escapeHtml(selfServe.contactEmail)}</a> for more.
557
+ </p>`
558
+ : ""
559
+ }
560
+ <p style="font-size:13px;color:#6b7280;margin-bottom:4px">Have these ready before you start:</p>
561
+ <ul style="font-size:13px;color:#6b7280;margin-left:20px">
562
+ <li>A Slack App Configuration Token</li>
563
+ <li>A GitHub org with an App, or a personal access token (PAT)</li>
564
+ <li>A Claude API key or OAuth token</li>
565
+ </ul>
566
+ </div>`
567
+ : "";
568
+ const typeFieldHtml = selfServe
569
+ ? `<input type="hidden" name="type" value="coding" />`
570
+ : `<div class="form-group">
571
+ <label class="form-label" for="type">Specialty <span style="color:#ef4444">*</span></label>
572
+ <select id="type" name="type" class="form-input" required>
573
+ ${typeOptions}
574
+ </select>
575
+ </div>`;
576
+ return renderAdminPage({
577
+ title: "New Agent — Shipwright Admin",
578
+ body: `${renderAdminToolbar(userName, "/admin/agents")}
579
+ <div class="vos-page">
580
+ <div class="page-header">
581
+ <div>
582
+ <a href="/admin/agents" style="font-size:13px;color:#6b7280;text-decoration:none">← Agents</a>
583
+ <h1 class="page-title" style="margin-top:4px">New Agent</h1>
584
+ </div>
585
+ </div>
586
+ ${errorHtml}
587
+ ${selfServeHtml}
588
+ <div class="card">
589
+ <p style="font-size:14px;color:#6b7280;margin-bottom:20px">
590
+ Create an agent and choose where it runs. Slack and GitHub connections
591
+ below are optional — you can always talk to the agent from the
592
+ <a href="/admin/chat">Chat</a> tab first and connect them later from
593
+ the agent detail page.
594
+ </p>
595
+ <form method="POST" action="/admin/agents" enctype="multipart/form-data" style="display:flex;flex-direction:column;gap:16px">
596
+ <div class="form-group">
597
+ <label class="form-label" for="name">Agent name <span style="color:#ef4444">*</span></label>
598
+ <input
599
+ id="name"
600
+ name="name"
601
+ type="text"
602
+ class="form-input"
603
+ placeholder="my-agent"
604
+ required
605
+ autofocus
606
+ />
607
+ </div>
608
+ ${typeFieldHtml}
609
+ <fieldset style="border:1px solid #e8e8ee;border-radius:8px;padding:16px">
610
+ <legend style="font-size:13px;font-weight:600;padding:0 8px">Runtime</legend>
611
+ <div class="form-group" style="margin-bottom:0">
612
+ <label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
613
+ <input type="radio" name="runtime" value="in-cluster" ${canProvision ? "checked" : "disabled"}
614
+ onchange="${runtimeSectionsOnchange("")}"
615
+ />
616
+ Provisioned in-cluster
617
+ <span style="font-weight:400;color:#6b7280">
618
+ — the admin service creates the Deployment, Secret, and PVC for you.
619
+ </span>
620
+ </label>
621
+ <label style="display:block;font-size:13px;font-weight:500">
622
+ <input type="radio" name="runtime" value="self-hosted" ${canProvision ? "" : "checked"}
623
+ onchange="${runtimeSectionsOnchange("none")}"
624
+ />
625
+ Self-hosted
626
+ <span style="font-weight:400;color:#6b7280">
627
+ — you run the container yourself (<span class="mono">task stack</span>, local Docker).
628
+ </span>
629
+ </label>
630
+ ${
631
+ canProvision
632
+ ? ""
633
+ : `<p style="font-size:12px;color:#6b7280;margin-top:8px">
634
+ In-cluster provisioning is unavailable — <span class="mono">SHIPWRIGHT_K8S_PROVISIONING</span> is not enabled on this admin service.
635
+ </p>`
636
+ }
637
+ </div>
638
+ </fieldset>
639
+ ${claudeCredentialFields}
640
+ <div class="form-group">
641
+ <label class="form-label" for="repos">Repos (optional, one per line)</label>
642
+ <textarea
643
+ id="repos"
644
+ name="repos"
645
+ class="form-input"
646
+ rows="4"
647
+ placeholder="my-org/repo-one&#10;my-org/repo-two"
648
+ ></textarea>
649
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">Format: <span class="mono">org/repo</span></p>
650
+ </div>
651
+ <div class="form-group">
652
+ <label class="form-label" for="authorAllowlist">Author allowlist (review) (optional, one GitHub login per line)</label>
653
+ <textarea
654
+ id="authorAllowlist"
655
+ name="authorAllowlist"
656
+ class="form-input"
657
+ rows="4"
658
+ placeholder="octocat&#10;another-user"
659
+ ></textarea>
660
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">GitHub login, one per line</p>
661
+ </div>
662
+ <div class="form-group">
663
+ <label class="form-label" for="patchAuthorAllowlist">Patch author allowlist (optional, one GitHub login per line)</label>
664
+ <textarea
665
+ id="patchAuthorAllowlist"
666
+ name="patchAuthorAllowlist"
667
+ class="form-input"
668
+ rows="4"
669
+ placeholder="octocat&#10;another-user"
670
+ ></textarea>
671
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">GitHub login, one per line</p>
672
+ </div>
673
+ <div id="restrict-slack-group" style="display:${runtimeSectionsDisplay}">
674
+ <div class="form-group" style="display:flex;align-items:center;gap:6px">
675
+ <input
676
+ id="restrictSlackToMembers"
677
+ name="restrictSlackToMembers"
678
+ type="checkbox"
679
+ value="true"
680
+ onchange="document.getElementById('member-emails-fields').style.display=this.checked?'block':'none'"
681
+ />
682
+ <label class="form-label" for="restrictSlackToMembers" style="margin-bottom:0">Restrict Slack to members</label>
683
+ </div>
684
+ <p style="font-size:12px;color:#6b7280;margin-top:-12px">
685
+ When enabled, only AgentMember emails may message this agent over Slack. If the agent has no
686
+ members yet, enabling this will block all Slack senders.
687
+ </p>
688
+ <div id="member-emails-fields" style="display:none;margin-top:12px">
689
+ <div class="form-group">
690
+ <label class="form-label" for="memberEmails">Member emails (optional, one per line)</label>
691
+ <textarea
692
+ id="memberEmails"
693
+ name="memberEmails"
694
+ class="form-input"
695
+ rows="4"
696
+ placeholder="alice@example.com&#10;bob@example.com"
697
+ ></textarea>
698
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">Email address, one per line</p>
699
+ </div>
700
+ </div>
701
+ </div>
702
+ <fieldset id="slack-section" style="display:${runtimeSectionsDisplay};border:1px solid #e8e8ee;border-radius:8px;padding:16px">
703
+ <legend style="font-size:13px;font-weight:600;padding:0 8px">Slack (optional)</legend>
704
+ <div class="form-group" style="display:flex;align-items:center;gap:6px;margin-bottom:0">
705
+ <input
706
+ id="connectSlack"
707
+ name="connectSlack"
708
+ type="checkbox"
709
+ value="true"
710
+ onchange="document.getElementById('slack-fields').style.display=this.checked?'block':'none'"
711
+ />
712
+ <label class="form-label" for="connectSlack" style="margin-bottom:0">Connect Slack</label>
713
+ </div>
714
+ <div id="slack-fields" style="display:none;margin-top:12px">
715
+ <div class="form-group">
716
+ <label class="form-label" for="xoxpToken">Slack App Configuration Token</label>
717
+ <input
718
+ id="xoxpToken"
719
+ name="xoxpToken"
720
+ type="password"
721
+ class="form-input"
722
+ placeholder="xoxe.xoxp-..."
723
+ />
724
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">
725
+ This token is used once to create the Slack app manifest. It is not stored. After
726
+ creating the agent you'll be redirected to authorize the Slack app.
727
+ </p>
728
+ </div>
729
+ </div>
730
+ </fieldset>
731
+ <fieldset id="github-section" style="display:${runtimeSectionsDisplay};border:1px solid #e8e8ee;border-radius:8px;padding:16px">
732
+ <legend style="font-size:13px;font-weight:600;padding:0 8px">GitHub Authentication (optional)</legend>
733
+ <!--
734
+ ghAppMode disambiguates the two ghAuthMode="app" radios below
735
+ (Create GitHub App = auto, Use existing GitHub App = manual).
736
+ Each app-mode radio's onchange sets this hidden field alongside
737
+ showing/hiding its own fields block; it defaults to "auto" so a
738
+ plain ghAuthMode=app submission (the historical default) is
739
+ unaffected. See UAP-5.3.
740
+ -->
741
+ <input type="hidden" id="ghAppMode" name="ghAppMode" value="auto" />
742
+ <div class="form-group" style="margin-bottom:12px">
743
+ <label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
744
+ <input type="radio" name="ghAuthMode" value="skip" checked
745
+ onchange="document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='none'"
746
+ /> Skip
747
+ <span style="font-weight:400;color:#6b7280">
748
+ — connect GitHub later from the agent detail page.
749
+ </span>
750
+ </label>
751
+ <label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
752
+ <input type="radio" name="ghAuthMode" value="pat"
753
+ onchange="document.getElementById('gh-pat-fields').style.display='block';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='none'"
754
+ /> Personal Access Token
755
+ </label>
756
+ <label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
757
+ <input type="radio" name="ghAuthMode" value="app"
758
+ onchange="document.getElementById('ghAppMode').value='auto';document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='block';document.getElementById('gh-app-manual-fields').style.display='none'"
759
+ /> Create GitHub App
760
+ </label>
761
+ <label style="display:block;font-size:13px;font-weight:500">
762
+ <input type="radio" name="ghAuthMode" value="app"
763
+ onchange="document.getElementById('ghAppMode').value='manual';document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='block'"
764
+ /> Use existing GitHub App
765
+ <span style="font-weight:400;color:#6b7280">
766
+ — connect a GitHub App you've already created.
767
+ </span>
768
+ </label>
769
+ </div>
770
+ <div id="gh-pat-fields" style="display:none">
771
+ <div class="form-group">
772
+ <label class="form-label" for="ghPat">Personal Access Token</label>
773
+ <input id="ghPat" name="ghPat" type="password" class="form-input" placeholder="ghp_..." />
774
+ </div>
775
+ </div>
776
+ <div id="gh-app-fields" style="display:none">
777
+ <div class="form-group">
778
+ <label class="form-label" for="githubOrg">GitHub org</label>
779
+ <input id="githubOrg" name="githubOrg" type="text" class="form-input" placeholder="my-org" />
780
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">
781
+ You'll be redirected to GitHub to create the App under this organization from a manifest.
782
+ </p>
783
+ </div>
784
+ </div>
785
+ <div id="gh-app-manual-fields" style="display:none">
786
+ <div class="form-group">
787
+ <label class="form-label" for="ghAppId">GitHub App ID</label>
788
+ <input id="ghAppId" name="ghAppId" type="text" class="form-input" placeholder="App ID" />
789
+ </div>
790
+ <div class="form-group">
791
+ <label class="form-label" for="ghAppInstallationId">Installation ID</label>
792
+ <input id="ghAppInstallationId" name="ghAppInstallationId" type="text" class="form-input" placeholder="Installation ID" />
793
+ </div>
794
+ <div class="form-group">
795
+ <label class="form-label" for="ghAppPrivateKeyFile">Private Key (.pem)</label>
796
+ <input id="ghAppPrivateKeyFile" name="ghAppPrivateKeyFile" type="file" accept=".pem" class="form-input" />
797
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">
798
+ Upload the private key <span class="mono">.pem</span> file you downloaded when creating the GitHub App.
799
+ </p>
800
+ </div>
801
+ </div>
802
+ </fieldset>
803
+ <div>
804
+ <button type="submit" class="btn btn-primary">Create agent →</button>
805
+ <a href="/admin/agents" class="btn btn-secondary" style="margin-left:8px">Cancel</a>
806
+ </div>
807
+ </form>
808
+ </div>
809
+ </div>`,
810
+ });
811
+ }
812
+
813
+ // ─── Agent detail page ────────────────────────────────────────────────────────
814
+
815
+ /**
816
+ * A single "connect later" action on the agent detail page (Connect Slack /
817
+ * Set up GitHub App / Add GitHub PAT / Use existing GitHub App) — a
818
+ * `<details>/<summary>` popover mirroring the "Sync Manifest" pattern.
819
+ * `envKey` is the per-agent AgentEnv key whose presence in `envVars` means
820
+ * this integration is already configured, hiding the action.
821
+ *
822
+ * `inputs` is an array so an action can collect more than one field (e.g.
823
+ * the manual GitHub App connect needs App ID + Installation ID + a Private
824
+ * Key file upload) — every existing action still supplies exactly one entry.
825
+ */
826
+ interface ConnectActionConfig {
827
+ envKey: string;
828
+ label: string;
829
+ description: string;
830
+ action: string;
831
+ hiddenFields?: Record<string, string>;
832
+ inputs: Array<{
833
+ name: string;
834
+ type: "text" | "password" | "file" | "checkbox";
835
+ placeholder?: string;
836
+ mono?: boolean;
837
+ /** Checkbox only: visible label text; `value` is submitted when checked. */
838
+ label?: string;
839
+ value?: string;
840
+ }>;
841
+ }
842
+
843
+ function renderConnectAction(
844
+ envVars: Record<string, string>,
845
+ cfg: ConnectActionConfig,
846
+ ): string {
847
+ if (cfg.envKey in envVars) return "";
848
+ const hiddenInputs = Object.entries(cfg.hiddenFields ?? {})
849
+ .map(
850
+ ([k, v]) =>
851
+ `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" />`,
852
+ )
853
+ .join("\n ");
854
+ const hasFileInput = cfg.inputs.some((input) => input.type === "file");
855
+ const visibleInputs = cfg.inputs
856
+ .map((input) =>
857
+ input.type === "checkbox"
858
+ ? `<label style="font-size:12px;display:flex;gap:6px;align-items:center">
859
+ <input name="${escapeHtml(input.name)}" type="checkbox" value="${escapeHtml(input.value ?? "true")}" />
860
+ ${escapeHtml(input.label ?? input.name)}
861
+ </label>`
862
+ : `<input
863
+ name="${escapeHtml(input.name)}"
864
+ type="${input.type}"
865
+ class="form-input${input.mono ? " mono" : ""}"
866
+ ${input.placeholder ? `placeholder="${escapeHtml(input.placeholder)}"` : ""}
867
+ required
868
+ style="font-size:12px"
869
+ />`,
870
+ )
871
+ .join("\n ");
872
+ return `<details style="position:relative">
873
+ <summary class="btn btn-secondary" style="cursor:pointer;font-size:12px;list-style:none">${escapeHtml(cfg.label)}</summary>
874
+ <div style="position:absolute;left:0;margin-top:6px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);z-index:10;min-width:320px">
875
+ <p style="font-size:12px;color:#6b7280;margin:0 0 10px">${cfg.description}</p>
876
+ <form method="POST" action="${cfg.action}"${hasFileInput ? ' enctype="multipart/form-data"' : ""} style="display:flex;flex-direction:column;gap:8px">
877
+ ${hiddenInputs}
878
+ ${visibleInputs}
879
+ <button type="submit" class="btn btn-primary" style="font-size:12px;align-self:flex-start">${escapeHtml(cfg.label)}</button>
880
+ </form>
881
+ </div>
882
+ </details>`;
883
+ }
884
+
885
+ /**
886
+ * Renders one collapsible accordion section on the agent detail page
887
+ * (AGA-1.1) — a `<details class="group">` wrapping a fixed subset of the
888
+ * page's existing cards. Always renders collapsed (no `open` attribute),
889
+ * including when `agent.missingRequiredEnv` is non-empty — there is no
890
+ * auto-open behavior for any group.
891
+ *
892
+ * `stat` is a short, pre-formatted, trusted (non-user-controlled) string
893
+ * (e.g. "5 system · 7 custom") rendered flush-right in the summary row.
894
+ * `name` (also trusted/static) is the group title, rendered flush-left.
895
+ * The toggle indicator (▸/▾) is a `::before` on the `.group-title` span
896
+ * itself — NOT on the flex `.group-summary` — so it stays part of that one
897
+ * flex child instead of becoming its own third flex item, which would break
898
+ * the flush-left/flush-right 2-sided layout into an uneven 3-way split.
899
+ */
900
+ function renderGroup(name: string, stat: string, bodyHtml: string): string {
901
+ return `<details class="group">
902
+ <summary class="group-summary">
903
+ <span class="group-title">${name}</span>
904
+ <span class="group-sub">${stat}</span>
905
+ </summary>
906
+ <div class="group-body">
907
+ ${bodyHtml}
908
+ </div>
909
+ </details>`;
910
+ }
911
+
912
+ export function renderAgentDetailPage(
913
+ agent: AgentDetail,
914
+ envResult:
915
+ | { env: Record<string, string>; secretKeys: string[] }
916
+ | Record<string, string>,
917
+ crons: CronJobItem[],
918
+ tools: ToolItem[],
919
+ tokens: TokenItem[],
920
+ plugins: PluginItem[],
921
+ members: MemberItem[],
922
+ userName: string,
923
+ isAdmin: boolean,
924
+ opts?: {
925
+ /**
926
+ * SSP-4.3: render the Members / Slack access / Danger Zone cards. Defaults
927
+ * to isAdmin; the caller passes true for agent-access holders when
928
+ * self-serve is enabled.
929
+ */
930
+ canManage?: boolean;
931
+ error?: string;
932
+ newToken?: string;
933
+ successMsg?: string;
934
+ /**
935
+ * Non-blocking warning surfaced when restrictSlackToMembers was just set
936
+ * to true on an agent with zero AgentMember rows — the save already
937
+ * succeeded, this is purely informational.
938
+ */
939
+ warning?: string;
940
+ now?: Date;
941
+ timezone?: string;
942
+ /**
943
+ * Pre-rendered extension card HTML for the "header" placement (AEP-2.1,
944
+ * admin-extension.ts), rendered in order above the stat strip. Absent or
945
+ * empty adds no card.
946
+ */
947
+ extensionCardsHtml?: readonly string[];
948
+ /**
949
+ * Pre-rendered extension card HTML for the "configuration" placement
950
+ * (AEP-3.2), rendered in order inside the Configuration group between
951
+ * Repos and Agent Policy. Absent or empty adds no card.
952
+ */
953
+ configurationCardsHtml?: readonly string[];
954
+ /**
955
+ * Render each cron's Logs link (AEP-3.6). Only the coding extension mounts
956
+ * /admin/agents/:id/queue-activity; absent/false omits the link.
957
+ */
958
+ queueActivityLinks?: boolean;
959
+ },
960
+ ): string {
961
+ // Reference time for relative timestamps — injected by tests for determinism,
962
+ // defaults to wall clock in production.
963
+ const now = opts?.now ?? new Date();
964
+ const timezone = opts?.timezone ?? "America/Los_Angeles";
965
+
966
+ // Normalise envResult — accept both the new {env, secretKeys} shape and the
967
+ // legacy Record<string,string> shape for backward compatibility.
968
+ const envVars =
969
+ envResult &&
970
+ "env" in envResult &&
971
+ typeof (envResult as { env: unknown }).env === "object"
972
+ ? (envResult as { env: Record<string, string>; secretKeys: string[] }).env
973
+ : (envResult as Record<string, string>);
974
+ const secretKeySet = new Set<string>(
975
+ envResult && "secretKeys" in envResult
976
+ ? (envResult as { secretKeys: string[] }).secretKeys
977
+ : [],
978
+ );
979
+
980
+ const envRows =
981
+ Object.keys(envVars).length === 0
982
+ ? `<tr><td colspan="3" class="empty-state">No env vars set.</td></tr>`
983
+ : Object.entries(envVars)
984
+ .map(
985
+ ([k]) => `<tr>
986
+ <td class="mono">${escapeHtml(k)}${secretKeySet.has(k) ? ' <span title="Secret">🔒</span>' : ""}</td>
987
+ <td class="mono" style="color:#6b7280">••••••••</td>
988
+ <td>
989
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/envs/delete" style="display:inline">
990
+ <input type="hidden" name="key" value="${escapeHtml(k)}" />
991
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
992
+ </form>
993
+ </td>
994
+ </tr>`,
995
+ )
996
+ .join("\n");
997
+
998
+ // "Connect later" actions (UAP-2.3) — each hidden once its AgentEnv key is
999
+ // already set, mirroring the "Sync Manifest" visibility pattern.
1000
+ const connectActionConfigs: ConnectActionConfig[] = [
1001
+ {
1002
+ envKey: "SLACK_APP_TOKEN",
1003
+ label: "Connect Slack",
1004
+ description: `Connects this agent to a Slack app. Requires a Slack app configuration token (<span class="mono">xoxe.xoxp-</span>).`,
1005
+ action: `/admin/agents/${escapeHtml(agent.id)}/connect-slack`,
1006
+ inputs: [
1007
+ {
1008
+ name: "xoxpToken",
1009
+ type: "password",
1010
+ placeholder: "xoxe.xoxp-...",
1011
+ mono: true,
1012
+ },
1013
+ ],
1014
+ },
1015
+ {
1016
+ envKey: "GH_APP_ID",
1017
+ label: "Set up GitHub App",
1018
+ description:
1019
+ "Creates a GitHub App for this agent from a manifest. You'll be redirected to GitHub to finish creating it under the chosen org.",
1020
+ action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
1021
+ hiddenFields: { ghAuthMode: "app", ghAppMode: "auto" },
1022
+ inputs: [
1023
+ { name: "githubOrg", type: "text", placeholder: "my-org" },
1024
+ {
1025
+ name: "ghAppPublic",
1026
+ type: "checkbox",
1027
+ value: "true",
1028
+ label: "Installable on multiple orgs (public App)",
1029
+ },
1030
+ ],
1031
+ },
1032
+ {
1033
+ envKey: "GH_APP_ID",
1034
+ label: "Use existing GitHub App",
1035
+ description:
1036
+ "Connects an existing GitHub App you've already created — paste its App ID and Installation ID, and upload its private key (PEM file).",
1037
+ action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
1038
+ hiddenFields: { ghAuthMode: "app", ghAppMode: "manual" },
1039
+ inputs: [
1040
+ { name: "ghAppId", type: "text", placeholder: "App ID" },
1041
+ {
1042
+ name: "ghAppInstallationId",
1043
+ type: "text",
1044
+ placeholder: "Installation ID",
1045
+ },
1046
+ { name: "ghAppPrivateKeyFile", type: "file" },
1047
+ ],
1048
+ },
1049
+ {
1050
+ envKey: "GH_TOKEN",
1051
+ label: "Add GitHub PAT",
1052
+ description:
1053
+ "Connects this agent to GitHub using a personal access token.",
1054
+ action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
1055
+ hiddenFields: { ghAuthMode: "pat" },
1056
+ inputs: [
1057
+ {
1058
+ name: "ghPat",
1059
+ type: "password",
1060
+ placeholder: "ghp_...",
1061
+ mono: true,
1062
+ },
1063
+ ],
1064
+ },
1065
+ ];
1066
+ const connectActions = [
1067
+ ...connectActionConfigs.map((cfg) => renderConnectAction(envVars, cfg)),
1068
+ renderAddAnotherOrgAction(agent.id, envVars),
1069
+ ].filter(Boolean);
1070
+
1071
+ const connectActionsHtml =
1072
+ connectActions.length === 0
1073
+ ? ""
1074
+ : `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
1075
+ ${connectActions.join("\n ")}
1076
+ </div>`;
1077
+
1078
+ // Top-level rows exclude any cron parented under another (e.g. shipwright-loop's
1079
+ // dev-task/review/patch/deploy phases) — those render nested beneath their
1080
+ // parent instead. `crons` (unfiltered) stays available below to look up each
1081
+ // top-level cron's children by parentCronId.
1082
+ const topLevelCrons = crons.filter((c) => !c.parentCronId);
1083
+ const systemCrons = topLevelCrons.filter((c) => c.system);
1084
+ const customCrons = topLevelCrons.filter((c) => !c.system);
1085
+
1086
+ /** Renders the reusable Logs/Toggle[/Delete] action cell shared by top-level and nested cron rows. */
1087
+ function renderCronActions(c: CronJobItem): string {
1088
+ const toggleLabel = c.enabled ? "Disable" : "Enable";
1089
+ const toggleTarget = c.enabled ? "false" : "true";
1090
+ const logsLink = opts?.queueActivityLinks
1091
+ ? `
1092
+ <a href="/admin/agents/${escapeHtml(agent.id)}/queue-activity?cronId=${escapeHtml(c.id)}" class="btn btn-secondary" style="font-size:11px;padding:3px 8px;margin-right:4px;text-decoration:none">Logs</a>`
1093
+ : "";
1094
+ return `${logsLink}
1095
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/toggle" style="display:inline">
1096
+ <input type="hidden" name="enabled" value="${toggleTarget}" />
1097
+ <button type="submit" class="btn btn-secondary" style="font-size:11px;padding:3px 8px">${toggleLabel}</button>
1098
+ </form>
1099
+ ${
1100
+ !c.system
1101
+ ? `<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/delete" style="display:inline;margin-left:4px">
1102
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
1103
+ </form>`
1104
+ : ""
1105
+ }`;
1106
+ }
1107
+
1108
+ /**
1109
+ * Renders the nested "Phases of <parent>" block for a top-level cron's
1110
+ * children (e.g. shipwright-loop's dev-task/review/patch/deploy phases).
1111
+ * Returns "" when there are no children, so parents without phases (all
1112
+ * custom crons, and any system cron other than shipwright-loop) render
1113
+ * exactly as before — no empty block.
1114
+ */
1115
+ function renderNestedPhasesRow(
1116
+ parent: CronJobItem,
1117
+ children: CronJobItem[],
1118
+ ): string {
1119
+ if (children.length === 0) return "";
1120
+ const parentLabel = parent.name ?? parent.id;
1121
+ const childRows = children
1122
+ .map((child) => {
1123
+ const lastRunOutcomeLabel = child.lastRun
1124
+ ? cronRunOutcomeLabel(child.lastRun)
1125
+ : null;
1126
+ return `<tr>
1127
+ <td style="padding-left:20px">${escapeHtml(child.name ? `${child.name}: ${child.prompt}` : child.prompt)}</td>
1128
+ <td class="mono">${escapeHtml(child.schedule)}</td>
1129
+ <td><span class="badge ${child.enabled ? "badge-green" : "badge-gray"}">${child.enabled ? "enabled" : "disabled"}</span></td>
1130
+ <td style="font-size:11px">${
1131
+ child.lastRun?.startedAt
1132
+ ? `<span class="badge" style="${cronOutcomeStyle(lastRunOutcomeLabel)}">${escapeHtml(lastRunOutcomeLabel ?? "unknown")}</span>`
1133
+ : `<span style="color:#d1d5db">never</span>`
1134
+ }</td>
1135
+ <td style="white-space:nowrap">${renderCronActions(child)}</td>
1136
+ </tr>`;
1137
+ })
1138
+ .join("\n");
1139
+ return `<tr>
1140
+ <td colspan="8" style="background:#f9fafb;padding:12px 12px 12px 32px">
1141
+ <div style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Phases of ${escapeHtml(parentLabel)}</div>
1142
+ <table class="data-table">
1143
+ <thead>
1144
+ <tr>
1145
+ <th>Phase</th>
1146
+ <th>Schedule</th>
1147
+ <th>Status</th>
1148
+ <th>Last run</th>
1149
+ <th></th>
1150
+ </tr>
1151
+ </thead>
1152
+ <tbody>
1153
+ ${childRows}
1154
+ </tbody>
1155
+ </table>
1156
+ </td>
1157
+ </tr>`;
1158
+ }
1159
+
1160
+ function renderCronRow(c: CronJobItem): string {
1161
+ const actions = renderCronActions(c);
1162
+ // Full inline edit (schedule, prompt, channel, preCheck), collapsed behind a
1163
+ // <details> so the table stays readable. Posts to /update → cronService.update.
1164
+ // System crons get NO edit form — their contents are owned by
1165
+ // reconcileSystemCrons and the /update route rejects them (mirrors delete).
1166
+ const editForm = c.system
1167
+ ? ""
1168
+ : `
1169
+ <details style="margin-top:6px">
1170
+ <summary style="cursor:pointer;font-size:11px;color:#6b7280">Edit</summary>
1171
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/update" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;min-width:240px">
1172
+ <input name="schedule" type="text" class="form-input mono" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.schedule)}" placeholder="0 * * * *" required title="Cron expression (5 fields)" />
1173
+ <textarea name="prompt" class="form-input" style="font-size:11px;padding:3px 6px;min-height:48px" placeholder="Prompt" required>${escapeHtml(c.prompt)}</textarea>
1174
+ <input name="channel" type="text" class="form-input" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.channel ?? "")}" placeholder="Channel ID (optional)" />
1175
+ <input name="preCheck" type="text" class="form-input mono" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.preCheck ?? "")}" placeholder="plugin:check.ts or ./path.ts (optional)" />
1176
+ <button type="submit" class="btn btn-primary" style="font-size:11px;padding:3px 8px;align-self:flex-start">Save</button>
1177
+ </form>
1178
+ </details>`;
1179
+ const lastRunOutcomeLabel = c.lastRun
1180
+ ? cronRunOutcomeLabel(c.lastRun)
1181
+ : null;
1182
+ const lastRunHtml = c.lastRun?.startedAt
1183
+ ? `
1184
+ <div style="font-size:11px;color:#6b7280;margin-bottom:4px">${relativeTime(c.lastRun.startedAt, now)}</div>
1185
+ <div>
1186
+ <span class="badge" style="${cronOutcomeStyle(lastRunOutcomeLabel)}">${escapeHtml(lastRunOutcomeLabel ?? "unknown")}</span>
1187
+ </div>
1188
+ <div style="font-size:11px;color:#6b7280;margin-top:4px">${c.runCountToday ?? 0} run${(c.runCountToday ?? 0) === 1 ? "" : "s"}</div>`
1189
+ : `<div style="color:#d1d5db">never</div>`;
1190
+
1191
+ const children = crons.filter((child) => child.parentCronId === c.id);
1192
+ const nestedPhasesRow = renderNestedPhasesRow(c, children);
1193
+
1194
+ return `<tr>
1195
+ <td class="mono">${escapeHtml(c.schedule)}</td>
1196
+ <td style="max-width:280px;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.name ? `${c.name}: ${c.prompt}` : c.prompt)}</td>
1197
+ <td class="mono" style="font-size:11px;color:#6b7280;max-width:170px;overflow:hidden;text-overflow:ellipsis">${c.preCheck ? escapeHtml(c.preCheck) : "—"}</td>
1198
+ <td>${c.channel ? escapeHtml(c.channel) : c.user ? escapeHtml(c.user) : "—"}</td>
1199
+ <td><span class="badge ${c.enabled ? "badge-green" : "badge-gray"}">${c.enabled ? "enabled" : "disabled"}</span></td>
1200
+ <td style="font-size:11px">${lastRunHtml}</td>
1201
+ <td class="col-created">${escapeHtml(new Date(c.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
1202
+ <td style="white-space:nowrap">${actions}${editForm}</td>
1203
+ </tr>
1204
+ ${nestedPhasesRow}`;
1205
+ }
1206
+
1207
+ const systemCronRows =
1208
+ systemCrons.length === 0
1209
+ ? `<tr><td colspan="8" class="empty-state">No system crons configured.</td></tr>`
1210
+ : systemCrons.map(renderCronRow).join("\n");
1211
+
1212
+ const customCronRows =
1213
+ customCrons.length === 0
1214
+ ? `<tr><td colspan="8" class="empty-state">No custom crons yet.</td></tr>`
1215
+ : customCrons.map(renderCronRow).join("\n");
1216
+
1217
+ const toolRows =
1218
+ tools.length === 0
1219
+ ? `<tr><td colspan="4" class="empty-state">No tools configured.</td></tr>`
1220
+ : tools
1221
+ .map(
1222
+ (t) => `<tr>
1223
+ <td class="mono">${escapeHtml(t.pattern)}</td>
1224
+ <td><span class="badge ${t.enabled ? "badge-green" : "badge-gray"}">${t.enabled ? "enabled" : "disabled"}</span></td>
1225
+ <td>${escapeHtml(new Date(t.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
1226
+ <td style="white-space:nowrap">
1227
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools/${escapeHtml(t.id)}/toggle" style="display:inline">
1228
+ <input type="hidden" name="enabled" value="${t.enabled ? "false" : "true"}" />
1229
+ <button type="submit" class="btn btn-secondary" style="font-size:11px;padding:3px 8px">${t.enabled ? "Disable" : "Enable"}</button>
1230
+ </form>
1231
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools/${escapeHtml(t.id)}/delete" style="display:inline;margin-left:4px">
1232
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
1233
+ </form>
1234
+ </td>
1235
+ </tr>`,
1236
+ )
1237
+ .join("\n");
1238
+
1239
+ const tokenRows =
1240
+ tokens.length === 0
1241
+ ? `<tr><td colspan="4" class="empty-state">No tokens created.</td></tr>`
1242
+ : tokens
1243
+ .map(
1244
+ (t) => `<tr>
1245
+ <td>${t.label ? escapeHtml(t.label) : '<span style="color:#9ca3af">—</span>'}</td>
1246
+ <td>${escapeHtml(new Date(t.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
1247
+ <td>${t.revokedAt ? `<span class="badge badge-gray">Revoked ${escapeHtml(new Date(t.revokedAt).toLocaleDateString("en-US", { timeZone: timezone }))}</span>` : '<span class="badge badge-green">Active</span>'}</td>
1248
+ <td>${
1249
+ !t.revokedAt
1250
+ ? `<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tokens/${escapeHtml(t.id)}/revoke" style="display:inline">
1251
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Revoke</button>
1252
+ </form>`
1253
+ : ""
1254
+ }</td>
1255
+ </tr>`,
1256
+ )
1257
+ .join("\n");
1258
+
1259
+ const pluginRows =
1260
+ plugins.length === 0
1261
+ ? `<tr><td colspan="4" class="empty-state">No plugins installed.</td></tr>`
1262
+ : plugins
1263
+ .map(
1264
+ (p) => `<tr>
1265
+ <td class="mono">${escapeHtml(p.name)}</td>
1266
+ <td class="mono">${p.version ? escapeHtml(p.version) : "latest"}</td>
1267
+ <td><span class="badge ${p.enabled ? "badge-green" : "badge-gray"}">${p.enabled ? "enabled" : "disabled"}</span></td>
1268
+ <td>${escapeHtml(new Date(p.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
1269
+ </tr>`,
1270
+ )
1271
+ .join("\n");
1272
+
1273
+ const memberRows =
1274
+ members.length === 0
1275
+ ? `<tr><td colspan="3" class="empty-state">No members yet.</td></tr>`
1276
+ : members
1277
+ .map(
1278
+ (m) => `<tr>
1279
+ <td>${escapeHtml(m.email)}</td>
1280
+ <td>${escapeHtml(new Date(m.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
1281
+ <td>
1282
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/members/delete" style="display:inline">
1283
+ <input type="hidden" name="memberId" value="${escapeHtml(m.id)}" />
1284
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
1285
+ </form>
1286
+ </td>
1287
+ </tr>`,
1288
+ )
1289
+ .join("\n");
1290
+
1291
+ const membersSection = `
1292
+ <div class="card">
1293
+ <div class="card-title">Members</div>
1294
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/members" style="margin-bottom:16px">
1295
+ <div class="form-row">
1296
+ <div class="form-group" style="flex:1">
1297
+ <input name="email" type="email" class="form-input" placeholder="user@example.com" required />
1298
+ </div>
1299
+ <button type="submit" class="btn btn-primary">Add</button>
1300
+ </div>
1301
+ </form>
1302
+ <div class="data-table-wrapper">
1303
+ <table class="data-table">
1304
+ <thead>
1305
+ <tr>
1306
+ <th>Email</th>
1307
+ <th>Added</th>
1308
+ <th></th>
1309
+ </tr>
1310
+ </thead>
1311
+ <tbody>
1312
+ ${memberRows}
1313
+ </tbody>
1314
+ </table>
1315
+ </div>
1316
+ </div>`;
1317
+
1318
+ const errorHtml = opts?.error
1319
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
1320
+ : "";
1321
+
1322
+ const successHtml = opts?.successMsg
1323
+ ? `<div class="alert alert-success">${escapeHtml(opts.successMsg)}</div>`
1324
+ : "";
1325
+
1326
+ const warningHtml = opts?.warning
1327
+ ? `<div class="alert alert-warning">${escapeHtml(opts.warning)}</div>`
1328
+ : "";
1329
+
1330
+ const newTokenHtml = opts?.newToken
1331
+ ? `<div class="alert alert-success">
1332
+ <strong>Token created.</strong> Copy it now — it will not be shown again.<br />
1333
+ <code class="mono" style="display:block;margin-top:8px;font-size:13px;word-break:break-all">${escapeHtml(opts.newToken)}</code>
1334
+ </div>`
1335
+ : "";
1336
+
1337
+ const envVarsSection = !agent.selfHosted
1338
+ ? `<div class="card" id="env-vars">
1339
+ <div class="card-title">Env Vars</div>
1340
+ ${
1341
+ agent.missingRequiredEnv.length > 0
1342
+ ? `<div class="alert alert-warning">
1343
+ Missing required env var${agent.missingRequiredEnv.length === 1 ? "" : "s"}:
1344
+ ${agent.missingRequiredEnv.map((key) => `<span class="badge badge-warning">${escapeHtml(key)}</span>`).join(" ")}
1345
+ — <a href="#env-vars">set ${agent.missingRequiredEnv.length === 1 ? "it" : "them"} below</a>.
1346
+ </div>`
1347
+ : ""
1348
+ }
1349
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/envs" style="margin-bottom:16px">
1350
+ <div class="form-row" style="flex-wrap:wrap;gap:8px">
1351
+ <div class="form-group">
1352
+ <input name="key" type="text" class="form-input" placeholder="KEY" required />
1353
+ </div>
1354
+ <div class="form-group">
1355
+ <input name="value" type="password" class="form-input" placeholder="value" required />
1356
+ </div>
1357
+ <div class="form-group" style="display:flex;align-items:center;gap:6px;padding-bottom:2px">
1358
+ <input name="secret" type="checkbox" id="env-secret" value="true" checked />
1359
+ <label for="env-secret" class="form-label" style="margin-bottom:0">Mark as secret</label>
1360
+ </div>
1361
+ <button type="submit" class="btn btn-primary">Add</button>
1362
+ </div>
1363
+ </form>
1364
+ <div class="data-table-wrapper">
1365
+ <table class="data-table">
1366
+ <thead>
1367
+ <tr>
1368
+ <th>Key</th>
1369
+ <th>Value</th>
1370
+ <th></th>
1371
+ </tr>
1372
+ </thead>
1373
+ <tbody>
1374
+ ${envRows}
1375
+ </tbody>
1376
+ </table>
1377
+ </div>
1378
+ </div>`
1379
+ : "";
1380
+
1381
+ const reposSection = `<div class="card">
1382
+ <div class="card-title">Repos</div>
1383
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/repos/add" style="margin-bottom:16px">
1384
+ <div class="form-row">
1385
+ <div class="form-group" style="flex:1">
1386
+ <input name="repo" type="text" class="form-input" placeholder="org/repo" required />
1387
+ </div>
1388
+ <button type="submit" class="btn btn-primary">Add</button>
1389
+ </div>
1390
+ </form>
1391
+ <div class="data-table-wrapper">
1392
+ <table class="data-table">
1393
+ <thead>
1394
+ <tr>
1395
+ <th>Repo</th>
1396
+ <th></th>
1397
+ </tr>
1398
+ </thead>
1399
+ <tbody>
1400
+ ${
1401
+ agent.repos.length === 0
1402
+ ? `<tr><td colspan="2" class="empty-state">No repos configured.</td></tr>`
1403
+ : agent.repos
1404
+ .map(
1405
+ (repo) => `<tr>
1406
+ <td class="mono">${escapeHtml(repo)}</td>
1407
+ <td>
1408
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/repos/delete" style="display:inline">
1409
+ <input type="hidden" name="repo" value="${escapeHtml(repo)}" />
1410
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
1411
+ </form>
1412
+ </td>
1413
+ </tr>`,
1414
+ )
1415
+ .join("\n")
1416
+ }
1417
+ </tbody>
1418
+ </table>
1419
+ </div>
1420
+ </div>`;
1421
+
1422
+ const authorAllowlistReviewSection = `<div class="card">
1423
+ <div class="card-title">Author allowlist (review)</div>
1424
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/review-author-allowlist/add" style="margin-bottom:16px">
1425
+ <div class="form-row">
1426
+ <div class="form-group" style="flex:1">
1427
+ <input name="login" type="text" class="form-input" placeholder="octocat" required />
1428
+ </div>
1429
+ <button type="submit" class="btn btn-primary">Add</button>
1430
+ </div>
1431
+ </form>
1432
+ <div class="data-table-wrapper">
1433
+ <table class="data-table">
1434
+ <thead>
1435
+ <tr>
1436
+ <th>GitHub login</th>
1437
+ <th></th>
1438
+ </tr>
1439
+ </thead>
1440
+ <tbody>
1441
+ ${
1442
+ agent.authorAllowlist.length === 0
1443
+ ? `<tr><td colspan="2" class="empty-state">No author allowlist entries configured.</td></tr>`
1444
+ : agent.authorAllowlist
1445
+ .map(
1446
+ (login) => `<tr>
1447
+ <td class="mono">${escapeHtml(login)}</td>
1448
+ <td>
1449
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/review-author-allowlist/delete" style="display:inline">
1450
+ <input type="hidden" name="login" value="${escapeHtml(login)}" />
1451
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
1452
+ </form>
1453
+ </td>
1454
+ </tr>`,
1455
+ )
1456
+ .join("\n")
1457
+ }
1458
+ </tbody>
1459
+ </table>
1460
+ </div>
1461
+ </div>`;
1462
+
1463
+ const authorAllowlistPatchSection = `<div class="card">
1464
+ <div class="card-title">Author allowlist (patch)</div>
1465
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/patch-author-allowlist/add" style="margin-bottom:16px">
1466
+ <div class="form-row">
1467
+ <div class="form-group" style="flex:1">
1468
+ <input name="login" type="text" class="form-input" placeholder="octocat" required />
1469
+ </div>
1470
+ <button type="submit" class="btn btn-primary">Add</button>
1471
+ </div>
1472
+ </form>
1473
+ <div class="data-table-wrapper">
1474
+ <table class="data-table">
1475
+ <thead>
1476
+ <tr>
1477
+ <th>GitHub login</th>
1478
+ <th></th>
1479
+ </tr>
1480
+ </thead>
1481
+ <tbody>
1482
+ ${
1483
+ (agent.patchAuthorAllowlist ?? []).length === 0
1484
+ ? `<tr><td colspan="2" class="empty-state">No patch author allowlist entries configured.</td></tr>`
1485
+ : (agent.patchAuthorAllowlist ?? [])
1486
+ .map(
1487
+ (login) => `<tr>
1488
+ <td class="mono">${escapeHtml(login)}</td>
1489
+ <td>
1490
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/patch-author-allowlist/delete" style="display:inline">
1491
+ <input type="hidden" name="login" value="${escapeHtml(login)}" />
1492
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
1493
+ </form>
1494
+ </td>
1495
+ </tr>`,
1496
+ )
1497
+ .join("\n")
1498
+ }
1499
+ </tbody>
1500
+ </table>
1501
+ </div>
1502
+ </div>`;
1503
+
1504
+ const canManage = opts?.canManage ?? isAdmin;
1505
+
1506
+ const slackAccessSection = canManage
1507
+ ? `<div class="card">
1508
+ <div class="card-title">Slack access</div>
1509
+ ${connectActionsHtml}
1510
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/settings">
1511
+ <div class="form-group" style="display:flex;align-items:center;gap:6px">
1512
+ <input
1513
+ id="restrictSlackToMembers"
1514
+ name="restrictSlackToMembers"
1515
+ type="checkbox"
1516
+ value="true"
1517
+ ${agent.restrictSlackToMembers ? "checked" : ""}
1518
+ />
1519
+ <!-- Hidden fallback: unchecked checkboxes are omitted from FormData
1520
+ entirely, so this "false" entry is what a submission of THIS
1521
+ form sends when unchecked. It also lets the shared /settings
1522
+ route (posted to by both this form and the Agent Policy form
1523
+ below) tell "this form was submitted with the box unchecked"
1524
+ apart from "a different form was submitted, this field is
1525
+ absent" — see admin-ui.ts's POST /admin/agents/:id/settings. -->
1526
+ <input type="hidden" name="restrictSlackToMembers" value="false" />
1527
+ <label class="form-label" for="restrictSlackToMembers" style="margin-bottom:0">Restrict Slack to members</label>
1528
+ </div>
1529
+ <p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
1530
+ When enabled, only AgentMember emails may message this agent over Slack. If the agent has no
1531
+ members yet, enabling this will block all Slack senders.
1532
+ </p>
1533
+ <button type="submit" class="btn btn-primary">Save</button>
1534
+ </form>
1535
+ </div>`
1536
+ : "";
1537
+
1538
+ const cronJobsSection = `<div class="card">
1539
+ <div class="card-title">Cron Jobs</div>
1540
+
1541
+ ${
1542
+ agent.selfHosted
1543
+ ? `<p style="font-size:13px;color:#6b7280;margin:0 0 16px">Crons fire only while the local agent service is running. Make sure your service is active before enabling crons.</p>`
1544
+ : ""
1545
+ }
1546
+
1547
+ <div style="margin-bottom:20px">
1548
+ <div style="font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">System</div>
1549
+ <div class="data-table-wrapper">
1550
+ <table class="data-table">
1551
+ <thead>
1552
+ <tr>
1553
+ <th>Schedule</th>
1554
+ <th>Prompt</th>
1555
+ <th>Pre-check</th>
1556
+ <th>Target</th>
1557
+ <th>Status</th>
1558
+ <th>Last run</th>
1559
+ <th class="col-created">Created</th>
1560
+ <th></th>
1561
+ </tr>
1562
+ </thead>
1563
+ <tbody>
1564
+ ${systemCronRows}
1565
+ </tbody>
1566
+ </table>
1567
+ </div>
1568
+ </div>
1569
+
1570
+ <div>
1571
+ <div style="font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Custom</div>
1572
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons" style="margin-bottom:12px">
1573
+ <div class="form-row" style="flex-wrap:wrap;gap:8px">
1574
+ <div class="form-group">
1575
+ <input name="schedule" type="text" class="form-input" placeholder="0 * * * *" required title="Cron expression (5 fields)" />
1576
+ </div>
1577
+ <div class="form-group" style="flex:1;min-width:200px">
1578
+ <input name="prompt" type="text" class="form-input" placeholder="Prompt" required />
1579
+ </div>
1580
+ <div class="form-group">
1581
+ <input name="channel" type="text" class="form-input" placeholder="Channel ID" />
1582
+ </div>
1583
+ <div class="form-group" style="display:flex;align-items:center;gap:6px;padding-bottom:2px">
1584
+ <input name="enabled" type="checkbox" id="cron-enabled" value="true" checked />
1585
+ <label for="cron-enabled" class="form-label" style="margin-bottom:0">Enabled</label>
1586
+ </div>
1587
+ <button type="submit" class="btn btn-primary">Add Cron</button>
1588
+ </div>
1589
+ </form>
1590
+ <div class="data-table-wrapper">
1591
+ <table class="data-table">
1592
+ <thead>
1593
+ <tr>
1594
+ <th>Schedule</th>
1595
+ <th>Prompt</th>
1596
+ <th>Pre-check</th>
1597
+ <th>Target</th>
1598
+ <th>Status</th>
1599
+ <th>Last run</th>
1600
+ <th class="col-created">Created</th>
1601
+ <th></th>
1602
+ </tr>
1603
+ </thead>
1604
+ <tbody>
1605
+ ${customCronRows}
1606
+ </tbody>
1607
+ </table>
1608
+ </div>
1609
+ </div>
1610
+ </div>`;
1611
+
1612
+ const toolsSection = `<div class="card">
1613
+ <div class="card-title">Tools</div>
1614
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools" style="margin-bottom:16px">
1615
+ <div class="form-row">
1616
+ <div class="form-group" style="flex:1">
1617
+ <input name="pattern" type="text" class="form-input" placeholder="Bash(git:*)" required />
1618
+ </div>
1619
+ <button type="submit" class="btn btn-primary">Add</button>
1620
+ </div>
1621
+ </form>
1622
+ <div class="data-table-wrapper">
1623
+ <table class="data-table">
1624
+ <thead>
1625
+ <tr>
1626
+ <th>Pattern</th>
1627
+ <th>Status</th>
1628
+ <th>Created</th>
1629
+ <th></th>
1630
+ </tr>
1631
+ </thead>
1632
+ <tbody>
1633
+ ${toolRows}
1634
+ </tbody>
1635
+ </table>
1636
+ </div>
1637
+ </div>`;
1638
+
1639
+ const tokensSection = `<div class="card">
1640
+ <div class="card-title">Task Store Tokens</div>
1641
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tokens" style="margin-bottom:16px">
1642
+ <div class="form-row">
1643
+ <div class="form-group" style="flex:1">
1644
+ <input name="label" type="text" class="form-input" placeholder="Label (optional)" />
1645
+ </div>
1646
+ <button type="submit" class="btn btn-primary">Create Token</button>
1647
+ </div>
1648
+ </form>
1649
+ <div class="data-table-wrapper">
1650
+ <table class="data-table">
1651
+ <thead>
1652
+ <tr>
1653
+ <th>Label</th>
1654
+ <th>Created</th>
1655
+ <th>Status</th>
1656
+ <th></th>
1657
+ </tr>
1658
+ </thead>
1659
+ <tbody>
1660
+ ${tokenRows}
1661
+ </tbody>
1662
+ </table>
1663
+ </div>
1664
+ </div>`;
1665
+
1666
+ const pluginsSection = `<div class="card">
1667
+ <div class="card-title">Plugins</div>
1668
+ <div class="data-table-wrapper">
1669
+ <table class="data-table">
1670
+ <thead>
1671
+ <tr>
1672
+ <th>Package</th>
1673
+ <th>Version</th>
1674
+ <th>Status</th>
1675
+ <th>Created</th>
1676
+ </tr>
1677
+ </thead>
1678
+ <tbody>
1679
+ ${pluginRows}
1680
+ </tbody>
1681
+ </table>
1682
+ </div>
1683
+ </div>`;
1684
+
1685
+ // PAI-2.5: read-only trace from this agent to its Anthropic identity. Values
1686
+ // are user data (owner email) or provider IDs, so every one is escaped.
1687
+ const identityPlaceholder = '<span style="color:#9ca3af">—</span>';
1688
+ const identityRow = (
1689
+ label: string,
1690
+ value: string | null | undefined,
1691
+ mono: boolean,
1692
+ ): string =>
1693
+ `<tr><td style="color:#6b7280;width:200px">${label}</td><td${mono ? ' class="mono"' : ""}>${value ? escapeHtml(value) : identityPlaceholder}</td></tr>`;
1694
+ const identitySection = `<div class="card">
1695
+ <div class="card-title">Owner &amp; Anthropic identity</div>
1696
+ <table class="detail-table"><tbody>
1697
+ ${identityRow("Owner email", agent.ownerEmail, false)}
1698
+ ${identityRow("Anthropic identity name", agent.anthropicIdentityName, true)}
1699
+ ${identityRow("Service account ID", agent.anthropicServiceAccountId, true)}
1700
+ ${identityRow("Federation rule ID", agent.anthropicFederationRuleId, true)}
1701
+ </tbody></table>
1702
+ </div>`;
1703
+
1704
+ // Only agents with a GitHub App can make API-signed commits. Checked state
1705
+ // is the SHIPWRIGHT_SIGNED_COMMITS env row; when the box is hidden the field
1706
+ // is absent from the submission, so the settings route leaves it untouched.
1707
+ const signedCommitsHtml =
1708
+ "GH_APP_ID" in envVars
1709
+ ? `<div class="form-group" style="display:flex;align-items:center;gap:6px">
1710
+ <input
1711
+ id="signedCommits"
1712
+ name="signedCommits"
1713
+ type="checkbox"
1714
+ value="true"
1715
+ ${envVars.SHIPWRIGHT_SIGNED_COMMITS === "1" ? "checked" : ""}
1716
+ />
1717
+ <input type="hidden" name="signedCommits" value="false" />
1718
+ <label class="form-label" for="signedCommits" style="margin-bottom:0">Signed commits</label>
1719
+ </div>
1720
+ <p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
1721
+ Commits are made through the GitHub API so they show Verified. Executable bits and symlinks need the per-call bypass.
1722
+ </p>`
1723
+ : "";
1724
+
1725
+ const agentPolicySection = `<div class="card">
1726
+ <div class="card-title">Agent Policy</div>
1727
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/settings">
1728
+ <div class="form-group" style="display:flex;align-items:center;gap:6px">
1729
+ <input
1730
+ id="autoPostReviews"
1731
+ name="autoPostReviews"
1732
+ type="checkbox"
1733
+ value="true"
1734
+ ${agent.autoPostReviews ? "checked" : ""}
1735
+ />
1736
+ <!-- Hidden "false" fallback so an unchecked box still submits a
1737
+ value (see the Slack access card's checkbox above for why). -->
1738
+ <input type="hidden" name="autoPostReviews" value="false" />
1739
+ <label class="form-label" for="autoPostReviews" style="margin-bottom:0">Auto-post reviews</label>
1740
+ </div>
1741
+ <p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
1742
+ Off stages reviews to state/reviews/ + Slack notify instead of posting directly to GitHub.
1743
+ </p>
1744
+ <div class="form-group" style="display:flex;align-items:center;gap:6px">
1745
+ <input
1746
+ id="allowSelfReview"
1747
+ name="allowSelfReview"
1748
+ type="checkbox"
1749
+ value="true"
1750
+ ${agent.allowSelfReview ? "checked" : ""}
1751
+ />
1752
+ <input type="hidden" name="allowSelfReview" value="false" />
1753
+ <label class="form-label" for="allowSelfReview" style="margin-bottom:0">Allow self-review</label>
1754
+ </div>
1755
+ <p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
1756
+ Whether the agent may review its own open PRs.
1757
+ </p>
1758
+ <div class="form-group" style="display:flex;align-items:center;gap:6px">
1759
+ <input
1760
+ id="cleanupMergedWorktrees"
1761
+ name="cleanupMergedWorktrees"
1762
+ type="checkbox"
1763
+ value="true"
1764
+ ${agent.cleanupMergedWorktrees ? "checked" : ""}
1765
+ />
1766
+ <input type="hidden" name="cleanupMergedWorktrees" value="false" />
1767
+ <label class="form-label" for="cleanupMergedWorktrees" style="margin-bottom:0">Cleanup merged worktrees</label>
1768
+ </div>
1769
+ <p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
1770
+ Remove worktrees for merged PRs each cron cycle.
1771
+ </p>
1772
+ <div class="form-group">
1773
+ <label class="form-label" for="minConfidence">Min confidence (0-100)</label>
1774
+ <input id="minConfidence" name="minConfidence" type="number" value="${agent.minConfidence}" class="form-input" />
1775
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">Minimum finding confidence to surface in a review.</p>
1776
+ </div>
1777
+ <div class="form-group">
1778
+ <label class="form-label" for="maxFindings">Max findings</label>
1779
+ <input id="maxFindings" name="maxFindings" type="number" value="${agent.maxFindings}" class="form-input" />
1780
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">Max findings surfaced per review.</p>
1781
+ </div>
1782
+ <div class="form-group">
1783
+ <label class="form-label" for="cleanupAfterDays">Cleanup after days</label>
1784
+ <input id="cleanupAfterDays" name="cleanupAfterDays" type="number" value="${agent.cleanupAfterDays}" class="form-input" />
1785
+ <p style="font-size:12px;color:#6b7280;margin-top:4px">Remove worktrees older than N days even if the PR is still open.</p>
1786
+ </div>
1787
+ ${signedCommitsHtml}
1788
+ <button type="submit" class="btn btn-primary">Save</button>
1789
+ </form>
1790
+ </div>`;
1791
+
1792
+ const dangerZoneSection = canManage
1793
+ ? `<!-- Danger Zone -->
1794
+ <div class="card" style="border:1px solid #fca5a5">
1795
+ <div class="card-title" style="color:#dc2626">Danger Zone</div>
1796
+ <p style="font-size:13px;color:#6b7280;margin-bottom:16px">
1797
+ Deleting this agent permanently removes all its data (env vars, crons, tools, tokens, plugins, members)
1798
+ and terminates its pod. This action cannot be undone.
1799
+ </p>
1800
+ <form id="delete-agent-form" method="POST" action="/admin/agents/${escapeHtml(agent.id)}/delete"
1801
+ data-agent-name="${escapeHtml(agent.name)}">
1802
+ <button type="submit" class="btn btn-danger">Delete agent</button>
1803
+ </form>
1804
+ <script>
1805
+ document.getElementById('delete-agent-form').addEventListener('submit', function(e) {
1806
+ var name = this.dataset.agentName;
1807
+ if (!confirm('Delete agent ' + name + '? This cannot be undone.')) {
1808
+ e.preventDefault();
1809
+ }
1810
+ });
1811
+ </script>
1812
+ </div>`
1813
+ : "";
1814
+
1815
+ // ─── Accordion groups (AGA-1.1) ────────────────────────────────────────
1816
+ // The cards below are grouped into 4 collapsed-by-default
1817
+ // <details class="group"> sections. Grouping is purely presentational —
1818
+ // every card's internal markup/ids/form actions are unchanged, they're
1819
+ // just composed into a new order. Agent Policy (added after this task was
1820
+ // originally scoped) lands in Configuration alongside Env Vars/Repos since
1821
+ // it is agent-level configuration, as do extension cards placed there
1822
+ // (the coding extension's Phase Methodology card) — this keeps every card
1823
+ // inside exactly one of the 4 groups, with no stray bare cards besides
1824
+ // Danger Zone.
1825
+ const automationStat = `${systemCrons.length} system · ${customCrons.length} custom`;
1826
+ const configurationStat = `${Object.keys(envVars).length} env vars · ${agent.repos.length} repos`;
1827
+ const activeTokenCount = tokens.filter((t) => !t.revokedAt).length;
1828
+ const accessStat = `${members.length} members · ${activeTokenCount} active tokens`;
1829
+ const pluginsToolsStat = `${tools.length} tools · ${plugins.length} plugins`;
1830
+
1831
+ // ─── Stat strip (AGA-1.2) ──────────────────────────────────────────────
1832
+ // 4-tile stat strip rendered above the 4 accordion groups. Each tile
1833
+ // derives its count from parameters already passed into this function.
1834
+ const cronsActiveCount = topLevelCrons.filter((c) => c.enabled).length;
1835
+ const missingEnvVarsCount = agent.missingRequiredEnv.length;
1836
+ const membersCount = members.length;
1837
+ const reposCount = agent.repos.length;
1838
+
1839
+ const statStripHtml = `<div class="stat-strip">
1840
+ <div class="stat-tile">
1841
+ <div class="stat-tile-label">crons active</div>
1842
+ <div class="stat-tile-value"><span>${cronsActiveCount}</span></div>
1843
+ </div>
1844
+ <div class="stat-tile${missingEnvVarsCount > 0 ? " stat-tile-warning" : ""}">
1845
+ <div class="stat-tile-label">missing env vars</div>
1846
+ <div class="stat-tile-value"><span>${missingEnvVarsCount}</span></div>
1847
+ </div>
1848
+ <div class="stat-tile">
1849
+ <div class="stat-tile-label">members</div>
1850
+ <div class="stat-tile-value"><span>${membersCount}</span></div>
1851
+ </div>
1852
+ <div class="stat-tile">
1853
+ <div class="stat-tile-label">repos</div>
1854
+ <div class="stat-tile-value"><span>${reposCount}</span></div>
1855
+ </div>
1856
+ </div>`;
1857
+
1858
+ const automationGroup = renderGroup(
1859
+ "Automation",
1860
+ automationStat,
1861
+ cronJobsSection,
1862
+ );
1863
+ const configurationGroup = renderGroup(
1864
+ "Configuration",
1865
+ configurationStat,
1866
+ `${envVarsSection}${reposSection}${(opts?.configurationCardsHtml ?? []).join("")}${agentPolicySection}`,
1867
+ );
1868
+ const accessGroup = renderGroup(
1869
+ "Access",
1870
+ accessStat,
1871
+ `${identitySection}${canManage ? membersSection : ""}${authorAllowlistReviewSection}${authorAllowlistPatchSection}${slackAccessSection}${tokensSection}`,
1872
+ );
1873
+ const pluginsToolsGroup = renderGroup(
1874
+ "Plugins &amp; Tools",
1875
+ pluginsToolsStat,
1876
+ `${toolsSection}${pluginsSection}`,
1877
+ );
1878
+
1879
+ const expandCollapseControls = `
1880
+ <div style="display:flex;gap:8px;margin-bottom:16px">
1881
+ <button type="button" class="btn btn-secondary" id="expand-all-btn">Expand all</button>
1882
+ <button type="button" class="btn btn-secondary" id="collapse-all-btn">Collapse all</button>
1883
+ </div>
1884
+ <script>
1885
+ (function() {
1886
+ var expandBtn = document.getElementById('expand-all-btn');
1887
+ var collapseBtn = document.getElementById('collapse-all-btn');
1888
+ if (expandBtn) {
1889
+ expandBtn.addEventListener('click', function() {
1890
+ document.querySelectorAll('.group').forEach(function(el) {
1891
+ el.open = true;
1892
+ });
1893
+ });
1894
+ }
1895
+ if (collapseBtn) {
1896
+ collapseBtn.addEventListener('click', function() {
1897
+ document.querySelectorAll('.group').forEach(function(el) {
1898
+ el.open = false;
1899
+ });
1900
+ });
1901
+ }
1902
+ })();
1903
+ </script>
1904
+ `;
1905
+
1906
+ return renderAdminPage({
1907
+ title: `${agent.name} — Shipwright Admin`,
1908
+ body: `${renderAdminToolbar(userName, "/admin/agents")}
1909
+ <div class="vos-page">
1910
+ <div class="page-header">
1911
+ <div>
1912
+ <a href="/admin/agents" style="font-size:13px;color:#6b7280;text-decoration:none">← Agents</a>
1913
+ <h1 class="page-title" style="margin-top:4px">${escapeHtml(agent.name)}</h1>
1914
+ <span style="font-size:13px;color:#6b7280">Type: <span class="mono">${escapeHtml(agent.typeName)}</span></span>
1915
+ ${agent.slackId ? `<span style="font-size:13px;color:#6b7280">Slack ID: <span class="mono">${escapeHtml(agent.slackId)}</span></span>` : ""}
1916
+ </div>
1917
+ ${
1918
+ agent.slackId
1919
+ ? `<div>
1920
+ <details>
1921
+ <summary class="btn btn-secondary" style="cursor:pointer;font-size:12px;list-style:none">Sync Manifest</summary>
1922
+ <div style="position:absolute;right:24px;margin-top:6px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);z-index:10;min-width:320px">
1923
+ <p style="font-size:12px;color:#6b7280;margin:0 0 10px">Syncs the current manifest to the provisioned Slack app. Requires a Slack app configuration token (<span class="mono">xoxe.xoxp-</span>).</p>
1924
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/sync-manifest" style="display:flex;flex-direction:column;gap:8px">
1925
+ <input
1926
+ name="xoxpToken"
1927
+ type="password"
1928
+ class="form-input mono"
1929
+ placeholder="xoxe.xoxp-..."
1930
+ required
1931
+ style="font-size:12px"
1932
+ />
1933
+ <button type="submit" class="btn btn-primary" style="font-size:12px;align-self:flex-start">Confirm Sync</button>
1934
+ </form>
1935
+ </div>
1936
+ </details>
1937
+ </div>`
1938
+ : ""
1939
+ }
1940
+ </div>
1941
+ ${errorHtml}
1942
+ ${successHtml}
1943
+ ${warningHtml}
1944
+ ${newTokenHtml}
1945
+ ${(opts?.extensionCardsHtml ?? []).join("\n ")}
1946
+
1947
+ ${statStripHtml}
1948
+
1949
+ ${expandCollapseControls}
1950
+ ${automationGroup}
1951
+ ${configurationGroup}
1952
+ ${accessGroup}
1953
+ ${pluginsToolsGroup}
1954
+
1955
+ ${dangerZoneSection}
1956
+
1957
+ </div>`,
1958
+ });
1959
+ }
1960
+
1961
+ // ─── Provision pages ──────────────────────────────────────────────────────────
1962
+
1963
+ /**
1964
+ * Auto-submitting POST-redirect page for the GitHub "create a GitHub App
1965
+ * from a manifest" flow: https://docs.github.com/apps/creating-github-apps/setting-up-a-github-app/creating-a-github-app-from-a-manifest
1966
+ *
1967
+ * `githubOrg` must already be validated by the caller against the org-name
1968
+ * pattern before this function is called — it's interpolated directly into
1969
+ * the form's `action` URL.
1970
+ */
1971
+ export function renderGithubAppManifestRedirectPage(
1972
+ userName: string,
1973
+ opts: { githubOrg: string; manifest: unknown },
1974
+ ): string {
1975
+ const actionUrl = `https://github.com/organizations/${encodeURIComponent(opts.githubOrg)}/settings/apps/new`;
1976
+ const manifestJson = JSON.stringify(opts.manifest);
1977
+
1978
+ return renderAdminPage({
1979
+ title: "Provision Agent — Shipwright Admin",
1980
+ body: `${renderAdminToolbar(userName, "/admin/provision")}
1981
+ <div class="vos-page">
1982
+ <div class="page-header">
1983
+ <h1 class="page-title">Provision Agent</h1>
1984
+ </div>
1985
+ <div class="provision-steps">
1986
+ <span class="provision-step active">1. Create Slack App</span>
1987
+ <span class="provision-step">2. Authorize</span>
1988
+ <span class="provision-step">3. Complete</span>
1989
+ </div>
1990
+ <div class="card">
1991
+ <p style="font-size:14px;margin-bottom:16px;color:#6b7280">
1992
+ Redirecting to GitHub to create the GitHub App under <strong>${escapeHtml(opts.githubOrg)}</strong>…
1993
+ </p>
1994
+ <form id="github-app-manifest-form" method="POST" action="${escapeHtml(actionUrl)}">
1995
+ <input type="hidden" name="manifest" value="${escapeHtml(manifestJson)}" />
1996
+ <button type="submit" class="btn btn-primary">Continue to GitHub →</button>
1997
+ </form>
1998
+ <script>
1999
+ document.getElementById('github-app-manifest-form').submit();
2000
+ </script>
2001
+ </div>
2002
+ </div>`,
2003
+ });
2004
+ }
2005
+
2006
+ /**
2007
+ * Shown after GET /admin/provision/github-app/complete succeeds — links the
2008
+ * operator to GitHub's installations/new page for the newly created app.
2009
+ */
2010
+ export function renderGithubAppInstallPage(
2011
+ userName: string,
2012
+ opts: { installUrl: string },
2013
+ ): string {
2014
+ return renderAdminPage({
2015
+ title: "Provision Agent — Shipwright Admin",
2016
+ body: `${renderAdminToolbar(userName, "/admin/provision")}
2017
+ <div class="vos-page">
2018
+ <div class="page-header">
2019
+ <h1 class="page-title">Provision Agent</h1>
2020
+ </div>
2021
+ <div class="provision-steps">
2022
+ <span class="provision-step">1. Create Slack App</span>
2023
+ <span class="provision-step active">2. Install GitHub App</span>
2024
+ <span class="provision-step">3. Complete</span>
2025
+ </div>
2026
+ <div class="card">
2027
+ <div class="alert alert-success">
2028
+ <strong>GitHub App created!</strong> Click the link below to install it.
2029
+ </div>
2030
+ <a href="${escapeHtml(opts.installUrl)}" class="btn btn-primary" rel="noopener noreferrer">
2031
+ Install GitHub App →
2032
+ </a>
2033
+ </div>
2034
+ </div>`,
2035
+ });
2036
+ }
2037
+
2038
+ /**
2039
+ * "Add another org" action for the agent detail page. Hidden until the agent
2040
+ * has a GitHub App (GH_APP_ID); links to the add-org redirect when GH_APP_SLUG
2041
+ * is stored, otherwise points at the docs.
2042
+ */
2043
+ export function renderAddAnotherOrgAction(
2044
+ agentId: string,
2045
+ envVars: Record<string, string>,
2046
+ ): string {
2047
+ if (!("GH_APP_ID" in envVars)) return "";
2048
+ if (envVars.GH_APP_SLUG) {
2049
+ return `<a href="/admin/agents/${escapeHtml(agentId)}/connect-github/add-org" class="btn btn-secondary" style="font-size:12px" rel="noopener noreferrer">Add another org</a>`;
2050
+ }
2051
+ return `<span style="font-size:12px;color:#6b7280">To install this App on another org, see <a href="https://github.com/app-vitals/shipwright/blob/main/docs/configuration-agent.md" rel="noopener noreferrer">the docs</a> (GH_APP_SLUG).</span>`;
2052
+ }
2053
+
2054
+ /**
2055
+ * Shown after GET /admin/provision/github-app/installed succeeds or fails —
2056
+ * the manifest flow's `setup_url` target.
2057
+ */
2058
+ export function renderGithubAppInstalledPage(
2059
+ userName: string,
2060
+ opts: {
2061
+ success: boolean;
2062
+ error?: string;
2063
+ /** Stored installation ID was kept; this was an additional-org install. */
2064
+ alreadyStored?: boolean;
2065
+ },
2066
+ ): string {
2067
+ const bodyHtml = opts.success
2068
+ ? `<div class="alert alert-success">
2069
+ ${
2070
+ opts.alreadyStored
2071
+ ? "<strong>Install received.</strong> — the agent's existing installation ID was left unchanged."
2072
+ : "<strong>GitHub App installed!</strong> — installation ID stored."
2073
+ }
2074
+ </div>
2075
+ <a href="/admin/provision" class="btn btn-secondary">Back to Provisioning</a>`
2076
+ : `<div class="alert alert-error">${escapeHtml(opts.error ?? "GitHub App installation failed.")}</div>
2077
+ <a href="/admin/provision" class="btn btn-secondary">Try again</a>`;
2078
+
2079
+ return renderAdminPage({
2080
+ title: "Provision Agent — Shipwright Admin",
2081
+ body: `${renderAdminToolbar(userName, "/admin/provision")}
2082
+ <div class="vos-page">
2083
+ <div class="page-header">
2084
+ <h1 class="page-title">Provision Agent</h1>
2085
+ </div>
2086
+ <div class="provision-steps">
2087
+ <span class="provision-step">1. Create Slack App</span>
2088
+ <span class="provision-step">2. Install GitHub App</span>
2089
+ <span class="provision-step active">3. Complete</span>
2090
+ </div>
2091
+ <div class="card">
2092
+ ${bodyHtml}
2093
+ </div>
2094
+ </div>`,
2095
+ });
2096
+ }
2097
+
2098
+ // xapp-token page shown after OAuth callback completes — user pastes the Socket Mode app token.
2099
+ export function renderProvisionXappTokenPage(
2100
+ userName: string,
2101
+ opts: { agentId: string; error?: string; warning?: string },
2102
+ ): string {
2103
+ const errorHtml = opts.error
2104
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
2105
+ : "";
2106
+ // Non-fatal warning (UAP-2.1) — e.g. a GitHub PAT that failed to store
2107
+ // during the combined create+connect flow. Surfaced here so the operator
2108
+ // isn't misled into believing GH_TOKEN was connected after finishing Slack.
2109
+ const warningHtml = opts.warning
2110
+ ? `<div class="alert alert-warning">${escapeHtml(opts.warning)}</div>`
2111
+ : "";
2112
+
2113
+ return renderAdminPage({
2114
+ title: "Complete Provisioning — Shipwright Admin",
2115
+ body: `${renderAdminToolbar(userName, "/admin/provision")}
2116
+ <div class="vos-page">
2117
+ <div class="page-header">
2118
+ <h1 class="page-title">Provision Agent</h1>
2119
+ </div>
2120
+ <div class="provision-steps">
2121
+ <span class="provision-step">1. Create Slack App</span>
2122
+ <span class="provision-step">2. Authorize</span>
2123
+ <span class="provision-step">3. Bot Token</span>
2124
+ <span class="provision-step active">4. Add Socket Token</span>
2125
+ </div>
2126
+ <div class="card">
2127
+ <p style="font-size:14px;margin-bottom:16px;color:#6b7280">
2128
+ Open your Slack App's <strong>Socket Mode</strong> settings, enable Socket Mode,
2129
+ and generate an <strong>App-Level Token</strong> with <code class="mono">connections:write</code> scope.
2130
+ Paste the <code class="mono">xapp-</code> token below.
2131
+ </p>
2132
+ ${warningHtml}
2133
+ ${errorHtml}
2134
+ <form method="POST" action="/admin/agents/${escapeHtml(opts.agentId)}/connect-slack/app-token">
2135
+ <div class="form-group">
2136
+ <label class="form-label" for="xappToken">App-Level Token (xapp-)</label>
2137
+ <input
2138
+ id="xappToken"
2139
+ name="xappToken"
2140
+ type="password"
2141
+ class="form-input"
2142
+ placeholder="xapp-..."
2143
+ required
2144
+ />
2145
+ </div>
2146
+ <button type="submit" class="btn btn-primary">Save Token →</button>
2147
+ </form>
2148
+ </div>
2149
+ </div>`,
2150
+ });
2151
+ }
2152
+
2153
+ export interface AgentOption {
2154
+ id: string;
2155
+ name: string;
2156
+ }
2157
+
2158
+ export function renderProvisionCompletePage(
2159
+ userName: string,
2160
+ opts: {
2161
+ success: boolean;
2162
+ agentId?: string;
2163
+ error?: string;
2164
+ },
2165
+ ): string {
2166
+ const bodyHtml = opts.success
2167
+ ? `<div class="alert alert-success">
2168
+ <strong>Provisioning complete!</strong> — Slack app credentials and tokens stored.
2169
+ </div>
2170
+ <p style="font-size:14px;margin-bottom:16px;margin-top:16px">
2171
+ All credentials have been saved to the agent's env vars and system crons have been seeded.
2172
+ For a self-hosted agent, mint its own API key from the agent's detail page.
2173
+ </p>
2174
+ ${opts.agentId ? `<a href="/admin/agents/${escapeHtml(opts.agentId)}" class="btn btn-primary">View Agent →</a>` : ""}
2175
+ <a href="/admin/agents" class="btn btn-secondary" style="margin-left:8px">Back to Agents</a>`
2176
+ : `<div class="alert alert-error">${escapeHtml(opts.error ?? "Provisioning failed.")}</div>
2177
+ <a href="/admin/provision" class="btn btn-secondary">Try again</a>`;
2178
+
2179
+ return renderAdminPage({
2180
+ title: "Provisioning Complete — Shipwright Admin",
2181
+ body: `${renderAdminToolbar(userName, "/admin/provision")}
2182
+ <div class="vos-page">
2183
+ <div class="page-header">
2184
+ <h1 class="page-title">Provision Agent</h1>
2185
+ </div>
2186
+ <div class="provision-steps">
2187
+ <span class="provision-step">1. Create Slack App</span>
2188
+ <span class="provision-step">2. Authorize</span>
2189
+ <span class="provision-step">3. Bot Token</span>
2190
+ <span class="provision-step active">4. Complete</span>
2191
+ </div>
2192
+ <div class="card">
2193
+ ${bodyHtml}
2194
+ </div>
2195
+ </div>`,
2196
+ });
2197
+ }
2198
+
2199
+ // ─── Chat page ─────────────────────────────────────────────────────────────────
2200
+
2201
+ /**
2202
+ * Renders the top-level /admin/chat page.
2203
+ *
2204
+ * @param agents - list of agents for the dropdown
2205
+ * @param selectedAgentId - the currently selected agent (from ?agentId=X)
2206
+ * @param threads - thread list (null = chatClient absent → degraded mode)
2207
+ * @param userName - logged-in user's email for the toolbar
2208
+ */
2209
+
2210
+ const threadPaneStyles = `
2211
+ .thread-pane-list { display:flex;flex-direction:column;gap:4px;margin-top:8px }
2212
+ .thread-pane-link { display:block;padding:8px 12px;border-radius:6px;font-size:13px;color:#374151;text-decoration:none;background:#f9fafb;border:1px solid #e5e7eb }
2213
+ .thread-pane-link:hover { background:#eef2ff;color:#4f46e5 }
2214
+ .thread-pane-link.active { background:#eef2ff;color:#4f46e5;font-weight:600 }`;
2215
+
2216
+ const chatPageStyles = `
2217
+ .chat-sidebar-new-thread-input { font-size:12px }
2218
+ @media (max-width:${BREAKPOINT_MOBILE_MAX}px) {
2219
+ .chat-sidebar-new-thread-input { font-size:16px }
2220
+ .chat-list-layout { flex-direction:column }
2221
+ .chat-list-sidebar { width:100%;max-width:100%;min-width:0 }
2222
+ /* chat-thread-layout: flex wrapper for thread+message area; stacks to column on mobile */
2223
+ .chat-thread-layout { flex-direction:column }
2224
+ /* ─── Mobile thread drawer (CFB-3.2) ──────────────────────────────────
2225
+ The sidebar becomes an off-canvas drawer instead of display:none, so
2226
+ the thread list is reachable on mobile. It is translated off the left
2227
+ edge by default and slid in when the checkbox is :checked. A scrim
2228
+ dims/blocks the background and closes the drawer on tap. */
2229
+ .chat-thread-sidebar {
2230
+ position:fixed;top:0;left:0;bottom:0;z-index:102;
2231
+ width:80%;max-width:300px;min-width:0;
2232
+ margin:0;border-radius:0;overflow-y:auto;
2233
+ transform:translateX(-100%);
2234
+ transition:transform 0.2s ease;
2235
+ }
2236
+ .chat-drawer-toggle:checked ~ .chat-thread-sidebar { transform:translateX(0) }
2237
+ /* Scrim toggles display (not just opacity) so it's genuinely absent from
2238
+ the a11y/visibility tree when closed — Playwright's toBeVisible() (and
2239
+ assistive tech) treat an opacity:0 element as still visible. */
2240
+ .chat-drawer-scrim {
2241
+ display:none;position:fixed;inset:0;z-index:101;
2242
+ background:rgba(0,0,0,0.4);cursor:pointer;
2243
+ }
2244
+ .chat-drawer-toggle:checked ~ .chat-drawer-scrim { display:block }
2245
+ .chat-drawer-hamburger { display:inline-block }
2246
+ @media (prefers-reduced-motion: reduce) {
2247
+ .chat-thread-sidebar { transition:none }
2248
+ }
2249
+ /* Collapse the header to a compact ~48px control row + 16px meta line so
2250
+ the message stream sits above the fold at 375px (criterion #4). The
2251
+ title rides inline in the control row; the thread-id meta stays as the
2252
+ single 16px line. Per-thread token stats and the rename/delete
2253
+ disclosure are pushed off-header on mobile (still reachable via the
2254
+ drawer / desktop) to keep the header under 96px. */
2255
+ .chat-thread-header { padding-top:2px;padding-bottom:2px }
2256
+ .chat-thread-header-top { margin-bottom:2px !important }
2257
+ .chat-thread-header .chat-drawer-hamburger { padding:4px 8px }
2258
+ .chat-thread-header .btn { margin-bottom:0 !important;padding:4px 10px }
2259
+ .chat-thread-header .page-title { font-size:15px;line-height:1.2;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis }
2260
+ .chat-thread-header .chat-thread-meta { margin-top:2px !important }
2261
+ .chat-thread-header .chat-thread-stats { display:none }
2262
+ .chat-thread-header .chat-thread-actions { display:none }
2263
+ .chat-bubble-inner { max-width:90% }
2264
+ #message-input { font-size:16px }
2265
+ /* Composer row: let the textarea keep adequate width instead of being
2266
+ squeezed to ~160px next to the Attach/Send buttons at narrow widths. */
2267
+ .chat-composer-row { flex-wrap:wrap }
2268
+ .chat-message-input { flex-basis:100%; }
2269
+ /* .chat-thread-page combines with .vos-page (specificity 0,2,0), which
2270
+ otherwise beats .vos-page's own mobile padding rule (0,1,0) — pin the
2271
+ effective mobile padding here. The bottom padding is intentionally
2272
+ small: the sticky composer carries its own safe-area bottom inset, and
2273
+ a large page bottom padding would steal scarce vertical space when the
2274
+ keyboard shrinks the viewport (CFB-3.2 criterion #3). */
2275
+ .chat-thread-page { padding:8px 12px 8px }
2276
+ /* Tighten the gap between the header and the message column so the
2277
+ message stream starts higher (criterion #4). */
2278
+ .chat-thread-layout { margin-top:8px !important }
2279
+ }`;
2280
+
2281
+ // ─── Chat thread page — class-driven bubble/composer styling (CFB-1.3) ────────
2282
+ // These class names are shared verbatim between the server-rendered HTML
2283
+ // (renderMessageBubble()) and the inline JS bubble builder (addBubble()) so
2284
+ // the two renderers physically cannot drift apart — see the interpolation of
2285
+ // CHAT_BUBBLE_CLASS/CHAT_BUBBLE_INNER_CLASS into `inlineScript` below.
2286
+ const CHAT_BUBBLE_CLASS = "chat-bubble";
2287
+ const CHAT_BUBBLE_INNER_CLASS = "chat-bubble-inner";
2288
+ const CHAT_BUBBLE_INNER_WIDE_CLASS = "chat-bubble-inner--wide";
2289
+
2290
+ /** e.g. "chat-bubble--user" — role is attacker-controlled only via server data, never used unescaped in an attribute here since roles are a closed set (user/assistant/system/other). */
2291
+ function chatBubbleRoleClass(role: string): string {
2292
+ return `${CHAT_BUBBLE_CLASS}--${role}`;
2293
+ }
2294
+
2295
+ // ─── Live progress (CFB-2.3) ──────────────────────────────────────────────────
2296
+
2297
+ /**
2298
+ * Human-readable label per errorKind. Single source of truth: used by the
2299
+ * server renderer (renderChatMessageBubble) AND serialized into the inline JS
2300
+ * (JSON.stringify(ERROR_KIND_LABELS)) so the client copy is generated from
2301
+ * this exact object and cannot drift — same pattern as CHAT_BUBBLE_CLASS.
2302
+ * A kind not present here falls back to DEFAULT_ERROR_LABEL.
2303
+ * `cancelled`/`incomplete`/`stalled` (CFB-2.4) are also retryable — see
2304
+ * RETRYABLE_ERROR_KINDS below.
2305
+ */
2306
+ export const ERROR_KIND_LABELS: Record<string, string> = {
2307
+ "rate-limited": "Rate limited",
2308
+ upstream: "Request failed",
2309
+ timeout: "Timed out",
2310
+ cancelled: "Cancelled",
2311
+ incomplete: "Incomplete",
2312
+ stalled: "Stalled",
2313
+ };
2314
+ const DEFAULT_ERROR_LABEL = "Error";
2315
+
2316
+ /** Resolve an errorKind to its label, with the shared default fallback. */
2317
+ function errorKindLabel(kind: string): string {
2318
+ return ERROR_KIND_LABELS[kind] ?? DEFAULT_ERROR_LABEL;
2319
+ }
2320
+
2321
+ /**
2322
+ * Error kinds recoverable via the Retry button (CFB-2.4) — the reply never
2323
+ * really completed (cancelled via heartbeat tick, cut off mid-stream, or
2324
+ * stalled out), so re-sending the originating user message is safe and
2325
+ * likely to succeed. Kinds like rate-limited/timeout/upstream are excluded:
2326
+ * those already represent a completed, failed round-trip.
2327
+ */
2328
+ const RETRYABLE_ERROR_KINDS: ReadonlySet<string> = new Set([
2329
+ "cancelled",
2330
+ "incomplete",
2331
+ "stalled",
2332
+ ]);
2333
+
2334
+ // Stable DOM id/class for the live status bubble (elapsed + milestone). The
2335
+ // server renders one when the last user message is unreplied, and the inline
2336
+ // ticker JS finds/updates/creates it by this exact id so the "just loaded,
2337
+ // agent still working" and "just sent, agent now working" bubbles are one and
2338
+ // the same element.
2339
+ const LIVE_STATUS_BUBBLE_ID = "live-status-bubble";
2340
+ const LIVE_STATUS_ELAPSED_ID = "live-status-elapsed";
2341
+ const LIVE_STATUS_MILESTONE_ID = "live-status-milestone";
2342
+ const STALL_INDICATOR_CLASS = "chat-stall-indicator";
2343
+
2344
+ /**
2345
+ * Layer-1 elapsed-timer guarantee: the client-side 1s ticker computes
2346
+ * `now - createdAt` with ZERO network dependency. Layer-2 milestone text
2347
+ * refreshes off the existing messages.json poll (adaptive 2s pending / 10s
2348
+ * idle). STALL_WARN_AFTER_MS is a *visible warning* (not a giveup) shown once
2349
+ * progressSeq has been unchanged for this long; ABSOLUTE_MAX_MS is the only
2350
+ * hard stop.
2351
+ */
2352
+ export const STALL_WARN_AFTER_MS = 120_000;
2353
+ // 65min — mirrors lib/claim-ttl.ts's DEFAULT_CLAIM_TTL_MS
2354
+ // (DEFAULT_CLAUDE_TIMEOUT_MS 1hr + CLAIM_TTL_BUFFER_MS 5min). Inline JS can't
2355
+ // import the lib file, so the value is hardcoded here with this reference.
2356
+ export const ABSOLUTE_MAX_MS = 3_900_000;
2357
+
2358
+ /**
2359
+ * Module-level pure bubble renderer (hoisted out of renderChatThreadPage in
2360
+ * CFB-2.3). Returns the exact HTML the server emits on a full page load; the
2361
+ * same string is returned as `bubbleHtml` from messages.json so polled bubbles
2362
+ * are byte-identical to reloaded ones. Every message bubble carries a stable
2363
+ * `data-message-id` so the client can dedupe via an id-based renderedIds set.
2364
+ *
2365
+ * `retryBody` (CFB-2.4) is the body of the most recent preceding user message
2366
+ * — when set and `m.errorKind` is one of RETRYABLE_ERROR_KINDS, the error
2367
+ * badge also renders a Retry button that resends that exact text. Callers
2368
+ * iterating a message list should track the last-seen user body and pass it
2369
+ * through per-message (see renderChatThreadPage and the messages.json route).
2370
+ *
2371
+ * `agentId` (VM-3.1) is needed to build the attachment-proxy URL
2372
+ * (`/admin/chat/:agentId/threads/:threadId/messages/:id/attachment`) used as
2373
+ * an audio attachment's `<audio src>` — defaults to "" for callers/tests that
2374
+ * don't pass it and don't care about attachment rendering.
2375
+ */
2376
+ export function renderChatMessageBubble(
2377
+ m: ChatMessage,
2378
+ retryBody: string | null = null,
2379
+ agentId = "",
2380
+ ): string {
2381
+ const isUser = m.role === "user";
2382
+ const isAssistant = m.role === "assistant";
2383
+ const isSystem = m.role === "system";
2384
+
2385
+ const bubbleRoleClass = chatBubbleRoleClass(
2386
+ isUser ? "user" : isAssistant ? "assistant" : isSystem ? "system" : "other",
2387
+ );
2388
+ const bubbleColor = isUser
2389
+ ? "#4f46e5"
2390
+ : isAssistant
2391
+ ? "#166534"
2392
+ : isSystem
2393
+ ? "#854d0e"
2394
+ : "#374151";
2395
+ const bubbleInnerClass = isSystem
2396
+ ? `${CHAT_BUBBLE_INNER_CLASS} ${CHAT_BUBBLE_INNER_WIDE_CLASS}`
2397
+ : CHAT_BUBBLE_INNER_CLASS;
2398
+
2399
+ // Render error badge if errorKind is set. cancelled/incomplete/stalled are
2400
+ // recoverable states (CFB-2.4), so they also render a Retry action that
2401
+ // re-sends the originating user message.
2402
+ let errorBadge = "";
2403
+ if (m.errorKind) {
2404
+ const retryable =
2405
+ RETRYABLE_ERROR_KINDS.has(m.errorKind) && retryBody !== null;
2406
+ const retryAction = retryable
2407
+ ? `<button type="button" class="chat-retry-btn" data-retry-body="${escapeHtml(retryBody as string)}" style="margin-left:8px;padding:2px 8px;background:#fff;color:#b91c1c;border:1px solid #b91c1c;border-radius:4px;font-size:12px;font-weight:600;cursor:pointer">Retry</button>`
2408
+ : "";
2409
+ errorBadge = `<div style="margin-top:6px;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600;display:inline-flex;align-items:center">${errorKindLabel(m.errorKind)}${retryAction}</div>`;
2410
+ }
2411
+
2412
+ // Parse markers from assistant messages to extract URLs/paths and clean text
2413
+ let cleanedBody = m.body;
2414
+ let markerBadges = "";
2415
+ if (isAssistant) {
2416
+ const { cleaned, uploads, planUrls } = parseChatMarkers(m.body);
2417
+ cleanedBody = cleaned;
2418
+
2419
+ // Render upload badges
2420
+ const uploadBadges = uploads
2421
+ .map((path) => {
2422
+ const filename = path.split("/").pop() || path;
2423
+ return `<div style="display:inline-block;margin-right:6px;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(filename)}</div>`;
2424
+ })
2425
+ .join("");
2426
+
2427
+ // Render plan links
2428
+ const planLinks = planUrls
2429
+ .map(
2430
+ (url) =>
2431
+ `<a href="${escapeHtml(url)}" target="_blank" style="display:inline-block;margin-right:6px;margin-top:8px;padding:3px 8px;background:#dbeafe;color:#1e40af;border-radius:6px;font-size:12px;text-decoration:none">View plan →</a>`,
2432
+ )
2433
+ .join("");
2434
+
2435
+ markerBadges = uploadBadges + planLinks;
2436
+ }
2437
+
2438
+ // Render body: assistant messages get markdown, others get escaped text
2439
+ const bodyHtml = isAssistant
2440
+ ? `<div style="font-size:14px;line-height:1.6;color:${bubbleColor}">${renderMarkdown(cleanedBody)}</div>`
2441
+ : `<div style="font-size:14px;white-space:pre-wrap;color:${bubbleColor}">${escapeHtml(m.body)}</div>`;
2442
+
2443
+ // Attachment: an inline <audio> player for audio attachments (VM-3.1) —
2444
+ // mic-recorded user messages and VM-2.1's TTS-authored assistant replies —
2445
+ // otherwise the plain filename badge. Audio-ness is inferred from the
2446
+ // filename extension (see attachment-validation.ts's isAudioFilename); the
2447
+ // src hits the admin attachment-proxy route, which streams the message's
2448
+ // bytes from the chat service with a real audio/* Content-Type.
2449
+ const attachmentMedia = m.attachmentFilename
2450
+ ? isAudioFilename(m.attachmentFilename)
2451
+ ? `<audio controls style="display:block;margin-top:8px;max-width:100%" src="${escapeHtml(`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}/attachment`)}"></audio>`
2452
+ : `<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(m.attachmentFilename)}</div>`
2453
+ : "";
2454
+
2455
+ let tokenBadge = "";
2456
+ if (isAssistant && m.tokens !== null && typeof m.tokens === "object") {
2457
+ const t = m.tokens as MessageTokens;
2458
+ const inTok = t.input_tokens ?? 0;
2459
+ const outTok = t.output_tokens ?? 0;
2460
+ const costPart = m.costUsd !== null ? ` · $${m.costUsd.toFixed(4)}` : "";
2461
+ tokenBadge = `<div style="font-size:11px;color:#6b7280;margin-top:4px">${escapeHtml(`${inTok} in / ${outTok} out${costPart}`)}</div>`;
2462
+ }
2463
+
2464
+ return `<div class="${CHAT_BUBBLE_CLASS} ${bubbleRoleClass}" data-message-id="${escapeHtml(m.id)}">
2465
+ <div class="${bubbleInnerClass}">
2466
+ <div style="font-size:11px;font-weight:600;color:${bubbleColor};margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em">${escapeHtml(m.role)}</div>
2467
+ ${bodyHtml}
2468
+ ${markerBadges}
2469
+ ${attachmentMedia}
2470
+ ${errorBadge}
2471
+ ${tokenBadge}
2472
+ <div style="font-size:11px;color:#9ca3af;margin-top:6px">${escapeHtml(new Date(m.createdAt).toLocaleString())}</div>
2473
+ </div>
2474
+ </div>`;
2475
+ }
2476
+
2477
+ /**
2478
+ * Server-rendered live status bubble shown when the last user message is
2479
+ * unreplied (`role === 'user' && !repliedAt`). Renders the elapsed timer seed
2480
+ * and (optionally) the current milestone. Uses stable ids so the inline
2481
+ * ticker finds and updates it on load without fabricating one. `progressPhase`
2482
+ * null ⇒ no milestone text (heartbeat hasn't reported a phase yet). The
2483
+ * elapsed seed is 0s server-side; the client ticker takes over immediately
2484
+ * from `data-created-at` with zero network dependency.
2485
+ */
2486
+ function renderLiveStatusBubble(m: ChatMessage): string {
2487
+ const milestone =
2488
+ m.progressPhase &&
2489
+ PROGRESS_LABELS[m.progressPhase as keyof typeof PROGRESS_LABELS]
2490
+ ? PROGRESS_LABELS[m.progressPhase as keyof typeof PROGRESS_LABELS]
2491
+ : "";
2492
+ const milestoneHtml = `<span id="${LIVE_STATUS_MILESTONE_ID}">${escapeHtml(milestone)}</span>`;
2493
+ const createdAtMs = new Date(m.createdAt).getTime();
2494
+ const seq = m.progressSeq ?? 0;
2495
+ return `<div id="${LIVE_STATUS_BUBBLE_ID}" class="${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}" data-created-at="${createdAtMs}" data-progress-seq="${seq}">
2496
+ <div class="${CHAT_BUBBLE_INNER_CLASS}">
2497
+ <div style="font-size:14px;color:#166534;font-style:italic">
2498
+ ${milestoneHtml}
2499
+ <span id="${LIVE_STATUS_ELAPSED_ID}">working… (0s)</span>
2500
+ </div>
2501
+ </div>
2502
+ </div>`;
2503
+ }
2504
+
2505
+ const chatThreadStyles = `
2506
+ /* The toolbar (.vos-toolbar) is a normal-flow sibling above .chat-thread-page,
2507
+ not fixed/absolute — so the page doesn't need to subtract a hardcoded
2508
+ toolbar height. Instead it grows to fill the remaining viewport via a
2509
+ flex-column body: min-height:100vh as a baseline, then min-height:100dvh
2510
+ as a progressive enhancement (dynamic viewport height accounts for
2511
+ mobile browser chrome that can grow the toolbar without breaking layout). */
2512
+ body { display:flex;flex-direction:column;min-height:100vh;min-height:100dvh;height:100dvh }
2513
+ .chat-thread-page { display:flex;flex-direction:column;flex:1;min-height:0;max-width:900px;margin:0 auto;padding:0 24px;width:100% }
2514
+ .chat-thread-header { padding-top:20px;padding-bottom:16px;flex-shrink:0 }
2515
+ /* Collapsed-by-default rename/delete disclosure (CFB-3.1 #5) — keeps the
2516
+ header compact above the fold regardless of viewport width. */
2517
+ .chat-thread-actions { margin-top:12px }
2518
+ .chat-thread-actions-summary { cursor:pointer;font-size:13px;color:#6b7280;user-select:none }
2519
+ .chat-thread-actions-summary:hover { color:#374151 }
2520
+ .chat-messages-container { flex:1;overflow-y:auto;overscroll-behavior:contain;padding:8px 0;min-height:0 }
2521
+ .${CHAT_BUBBLE_CLASS} { display:flex;margin-bottom:12px }
2522
+ .${chatBubbleRoleClass("user")} { justify-content:flex-end }
2523
+ .${chatBubbleRoleClass("assistant")} { justify-content:flex-start }
2524
+ .${chatBubbleRoleClass("system")} { justify-content:center }
2525
+ .${chatBubbleRoleClass("other")} { justify-content:flex-start }
2526
+ .${CHAT_BUBBLE_INNER_CLASS} { max-width:70%;border-radius:12px;padding:12px 16px;box-shadow:0 1px 2px rgba(0,0,0,0.06);overflow-wrap:anywhere }
2527
+ /* renderMarkdown emits <pre><code> for fenced blocks — let long code lines
2528
+ scroll horizontally inside the bubble instead of forcing the whole page
2529
+ to overflow. Scoped to the bubble so it doesn't affect other <pre>. */
2530
+ .${CHAT_BUBBLE_INNER_CLASS} pre { overflow-x:auto }
2531
+ .${CHAT_BUBBLE_INNER_CLASS}.${CHAT_BUBBLE_INNER_WIDE_CLASS} { max-width:80% }
2532
+ .${chatBubbleRoleClass("user")} .${CHAT_BUBBLE_INNER_CLASS} { background:#eef2ff }
2533
+ .${chatBubbleRoleClass("assistant")} .${CHAT_BUBBLE_INNER_CLASS} { background:#f0fdf4 }
2534
+ .${chatBubbleRoleClass("system")} .${CHAT_BUBBLE_INNER_CLASS} { background:#fef9c3 }
2535
+ .${chatBubbleRoleClass("other")} .${CHAT_BUBBLE_INNER_CLASS} { background:#f3f4f6 }
2536
+ /* Sticky composer: pins to the bottom of the scroll container so it stays
2537
+ reachable as messages grow. padding-bottom folds in the iOS safe-area
2538
+ inset plus --kb-inset (set by the visualViewport listener when the
2539
+ on-screen keyboard shrinks the visual viewport) so the composer isn't
2540
+ slid under the keyboard on iOS, where dvh does NOT shrink the layout. */
2541
+ .chat-composer-form { flex-shrink:0;position:sticky;bottom:0;background:#f7f7fb;padding:16px 0;padding-bottom:calc(12px + env(safe-area-inset-bottom) + var(--kb-inset, 0px));border-top:1px solid #e5e7eb;margin-top:8px }
2542
+ .chat-composer-row { display:flex;gap:8px;align-items:flex-end }
2543
+ .chat-message-input { flex:1;resize:vertical;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;font-family:inherit;line-height:1.5;outline:none }
2544
+ .chat-composer-btn { flex-shrink:0;height:44px }
2545
+ .chat-composer-btn--attach { padding:0 16px }
2546
+ .chat-composer-btn--mic { padding:0 16px }
2547
+ .chat-composer-btn--mic.chat-mic-recording { background:#fee2e2;color:#b91c1c;border-color:#b91c1c }
2548
+ .chat-composer-btn--send { padding:0 20px }
2549
+ /* Live-progress stall state (CFB-2.3): when progressSeq hasn't advanced for
2550
+ STALL_WARN_AFTER_MS the status bubble gains .${STALL_INDICATOR_CLASS},
2551
+ which pulses to draw the eye. Motion is suppressed for users who ask for
2552
+ reduced motion — the amber colour still conveys the state statically. */
2553
+ .${STALL_INDICATOR_CLASS} { color:#b45309 }
2554
+ .${STALL_INDICATOR_CLASS} .${CHAT_BUBBLE_INNER_CLASS} { background:#fffbeb;animation:chat-stall-pulse 1.4s ease-in-out infinite }
2555
+ @keyframes chat-stall-pulse { 0%,100% { opacity:1 } 50% { opacity:0.55 } }
2556
+ @media (prefers-reduced-motion: reduce) {
2557
+ .${STALL_INDICATOR_CLASS} .${CHAT_BUBBLE_INNER_CLASS} { animation:none }
2558
+ }
2559
+ /* ─── Thread drawer (CFB-3.2) ────────────────────────────────────────────
2560
+ CSS-only collapsible sidebar mirroring the toolbar hamburger idiom. On
2561
+ desktop the sidebar is an ordinary flex column and the drawer chrome
2562
+ (toggle checkbox, scrim, hamburger) is inert/hidden — everything below
2563
+ only activates inside the mobile @media block in chatPageStyles. DOM
2564
+ order is load-bearing: the checkbox precedes the sidebar so the ~ sibling
2565
+ combinator can reach it. */
2566
+ .chat-drawer-toggle { position:absolute;opacity:0;width:1px;height:1px;pointer-events:none }
2567
+ .chat-drawer-hamburger { display:none;background:none;border:1px solid #e5e7eb;border-radius:6px;font-size:18px;line-height:1;padding:6px 10px;cursor:pointer;color:#374151 }
2568
+ .chat-drawer-hamburger:hover { color:#4f46e5 }
2569
+ .chat-drawer-scrim { display:none }`;
2570
+
2571
+ export function renderChatPage(
2572
+ agents: AgentOption[],
2573
+ selectedAgentId: string | undefined,
2574
+ threads: ChatThread[] | null,
2575
+ userName: string,
2576
+ q?: string,
2577
+ ): string {
2578
+ const activePath = "/admin/chat";
2579
+
2580
+ const agentOptions = agents
2581
+ .map(
2582
+ (a) =>
2583
+ `<option value="${escapeHtml(a.id)}"${a.id === selectedAgentId ? " selected" : ""}>${escapeHtml(a.name)}</option>`,
2584
+ )
2585
+ .join("\n");
2586
+
2587
+ const agentSelector = `
2588
+ <form method="GET" action="/admin/chat" class="form-row" style="margin-bottom:24px">
2589
+ <div class="form-group" style="max-width:320px">
2590
+ <label class="form-label" for="agentId">Agent</label>
2591
+ <select name="agentId" id="agentId" class="form-input" onchange="this.form.submit()">
2592
+ <option value="">— select an agent —</option>
2593
+ ${agentOptions}
2594
+ </select>
2595
+ </div>
2596
+ </form>`;
2597
+
2598
+ let content: string;
2599
+
2600
+ if (threads === null) {
2601
+ // Degraded mode — chat service not configured
2602
+ content = `
2603
+ <div class="alert alert-error">
2604
+ Chat service not configured. Set <code>SHIPWRIGHT_CHAT_SERVICE_URL</code> and
2605
+ <code>SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN</code> to enable this feature.
2606
+ </div>`;
2607
+ } else if (!selectedAgentId) {
2608
+ // No agent selected yet
2609
+ content = `
2610
+ <div class="empty-state">
2611
+ Select an agent above to view its threads.
2612
+ </div>`;
2613
+ } else {
2614
+ // Search box
2615
+ const searchForm = `
2616
+ <form method="GET" action="/admin/chat" class="form-row" style="margin-bottom:16px">
2617
+ <input type="hidden" name="agentId" value="${escapeHtml(selectedAgentId)}">
2618
+ <div class="form-group" style="max-width:320px">
2619
+ <input type="text" name="q" class="form-input" placeholder="Search threads…" value="${escapeHtml(q ?? "")}">
2620
+ </div>
2621
+ <button type="submit" class="btn btn-secondary">Search</button>
2622
+ </form>`;
2623
+
2624
+ // New thread form (above the thread list)
2625
+ const newThreadForm = `
2626
+ <form method="POST" action="/admin/chat/${escapeHtml(selectedAgentId)}/threads" style="margin-bottom:16px">
2627
+ <div class="form-row">
2628
+ <div class="form-group">
2629
+ <input type="text" name="title" class="form-input" placeholder="Thread title (optional)">
2630
+ </div>
2631
+ <button type="submit" class="btn btn-primary">New Thread</button>
2632
+ </div>
2633
+ </form>`;
2634
+
2635
+ // Thread list pane
2636
+ const threadLinks =
2637
+ threads.length === 0
2638
+ ? `<div class="empty-state" style="padding:12px">No threads found.</div>`
2639
+ : threads
2640
+ .map((t) => {
2641
+ const title = escapeHtml(t.title ?? "Untitled");
2642
+ return `<a href="/admin/chat/${escapeHtml(selectedAgentId)}/threads/${escapeHtml(t.id)}" class="thread-pane-link">${title}</a>`;
2643
+ })
2644
+ .join("\n");
2645
+
2646
+ content = `
2647
+ ${searchForm}
2648
+ <div class="chat-list-layout" style="display:flex;gap:24px;align-items:flex-start">
2649
+ <div class="card chat-list-sidebar" style="min-width:240px;max-width:300px;flex-shrink:0">
2650
+ <div class="card-title">Threads</div>
2651
+ ${newThreadForm}
2652
+ <div class="thread-pane-list">
2653
+ ${threadLinks}
2654
+ </div>
2655
+ </div>
2656
+ <div style="flex:1">
2657
+ <div class="empty-state">Select a thread from the list to view messages.</div>
2658
+ </div>
2659
+ </div>`;
2660
+ }
2661
+
2662
+ return renderAdminPage({
2663
+ title: "Chat — Shipwright Admin",
2664
+ extraStyles: `${threadPaneStyles}${chatPageStyles}
2665
+ `,
2666
+ body: `${renderAdminToolbar(userName, activePath)}
2667
+ <div class="vos-page">
2668
+ <div class="page-header">
2669
+ <h1 class="page-title">Chat</h1>
2670
+ </div>
2671
+ ${agentSelector}
2672
+ ${content}
2673
+ </div>`,
2674
+ });
2675
+ }
2676
+
2677
+ function formatTokenCount(n: number): string {
2678
+ if (n >= 1000) {
2679
+ return `${(n / 1000).toFixed(1)}k`;
2680
+ }
2681
+ return String(n);
2682
+ }
2683
+
2684
+ /**
2685
+ * Renders a thread detail page at /admin/chat/:agentId/threads/:threadId.
2686
+ *
2687
+ * @param agentId - agent ID
2688
+ * @param thread - thread object (null = chatClient absent -> degraded mode)
2689
+ * @param messages - messages for the thread (null = degraded mode)
2690
+ * @param threads - list of all threads for the sidebar pane (null = not available)
2691
+ * @param userName - logged-in user's email for the toolbar
2692
+ */
2693
+ export function renderChatThreadPage(
2694
+ agentId: string,
2695
+ thread: ChatThread | null,
2696
+ messages: ChatMessage[] | null,
2697
+ threadsOrUserName: ChatThread[] | null | string,
2698
+ userNameArg?: string,
2699
+ stats?: ThreadStats | null,
2700
+ opts?: {
2701
+ stallWarnAfterMs?: number;
2702
+ // Push-notification toggle (CFB-4.2). When push is disabled server-side
2703
+ // (VAPID not fully configured), pushEnabled is false / vapidPublicKey is
2704
+ // absent, renderPushToggle returns "", and the page degrades to exactly the
2705
+ // CFB-3.2 page (acceptance criterion 6).
2706
+ pushEnabled?: boolean;
2707
+ vapidPublicKey?: string;
2708
+ },
2709
+ ): string {
2710
+ // The stall-warning threshold is normally the 120s production default, but
2711
+ // the e2e suite overrides it to a tiny value (via a query param the route
2712
+ // handler forwards here) so the stall-state assertion doesn't need a real
2713
+ // two-minute wait. Production behaviour is unaffected.
2714
+ const stallWarnAfterMs = opts?.stallWarnAfterMs ?? STALL_WARN_AFTER_MS;
2715
+ // Support both 4-arg (threads omitted) and 5-arg call signatures
2716
+ const threads: ChatThread[] | null =
2717
+ typeof threadsOrUserName === "string" ? null : threadsOrUserName;
2718
+ const userName: string =
2719
+ typeof threadsOrUserName === "string"
2720
+ ? threadsOrUserName
2721
+ : (userNameArg ?? "");
2722
+ const activePath = "/admin/chat";
2723
+
2724
+ if (thread === null || messages === null) {
2725
+ return renderAdminPage({
2726
+ title: "Thread - Shipwright Admin",
2727
+ body: `${renderAdminToolbar(userName, activePath)}
2728
+ <div class="vos-page">
2729
+ <div class="page-header">
2730
+ <h1 class="page-title">Thread</h1>
2731
+ </div>
2732
+ <div class="alert alert-error">
2733
+ Chat service not configured. Set <code>SHIPWRIGHT_CHAT_SERVICE_URL</code> and
2734
+ <code>SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN</code> to enable this feature.
2735
+ </div>
2736
+ </div>`,
2737
+ });
2738
+ }
2739
+
2740
+ const threadId = thread.id;
2741
+ // rawTitle feeds renderAdminPage(), which HTML-escapes it itself; `title`
2742
+ // is the pre-escaped display string used inline in body markup below —
2743
+ // keep both to avoid double-escaping the <title> tag.
2744
+ const rawTitle = thread.title ?? "Untitled Thread";
2745
+ const title = thread.title ? escapeHtml(thread.title) : "Untitled Thread";
2746
+
2747
+ // Track the most recent user message body while iterating so an error
2748
+ // reply's Retry button (CFB-2.4) can resend the exact text that triggered
2749
+ // it. renderChatMessageBubble is the module-level renderer (hoisted in
2750
+ // CFB-2.3) so the same retryBody-aware logic is shared with the
2751
+ // messages.json poll route.
2752
+ let lastUserBody: string | null = null;
2753
+ const messageBubbles = messages
2754
+ .map((m) => {
2755
+ const html = renderChatMessageBubble(m, lastUserBody, agentId);
2756
+ if (m.role === "user") lastUserBody = m.body;
2757
+ return html;
2758
+ })
2759
+ .join("\n");
2760
+
2761
+ // Server-render the live status bubble when the last user message has no
2762
+ // reply yet — so a full-page reload mid-run shows the elapsed/milestone
2763
+ // immediately instead of a 3s-later blank until the first poll ticks in.
2764
+ const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
2765
+ const pendingMsg =
2766
+ lastMsg && lastMsg.role === "user" && !lastMsg.repliedAt ? lastMsg : null;
2767
+ const liveStatusBubble = pendingMsg ? renderLiveStatusBubble(pendingMsg) : "";
2768
+
2769
+ const emptyState =
2770
+ messages.length === 0
2771
+ ? `<div class="empty-state" style="text-align:center;padding:48px 24px;color:#9ca3af">No messages in this thread yet. Send a message to get started.</div>`
2772
+ : "";
2773
+
2774
+ const safeAgentId = escapeHtml(agentId);
2775
+ const safeThreadId = escapeHtml(threadId);
2776
+
2777
+ const renameForm = `
2778
+ <form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
2779
+ <div class="form-row" style="align-items:center;gap:8px">
2780
+ <input type="text" name="title" class="form-input" placeholder="New title…" style="max-width:240px" required>
2781
+ <button type="submit" class="btn btn-secondary" style="white-space:nowrap">Rename</button>
2782
+ </div>
2783
+ </form>`;
2784
+
2785
+ const deleteForm = `
2786
+ <form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/delete" style="margin-top:8px" onsubmit="return confirm('Delete this thread?')">
2787
+ <button type="submit" class="btn btn-danger">Delete Thread</button>
2788
+ </form>`;
2789
+
2790
+ // Collapsed by default so rename/delete controls don't eat vertical space
2791
+ // above the fold before any conversation is visible on a short mobile
2792
+ // viewport — a plain <details>/<summary> disclosure, no JS required.
2793
+ const threadActionsDetails = `
2794
+ <details class="chat-thread-actions">
2795
+ <summary class="chat-thread-actions-summary">Thread actions</summary>
2796
+ ${renameForm}
2797
+ ${deleteForm}
2798
+ </details>`;
2799
+
2800
+ // Inline JS for the send/poll/live-progress flow (CFB-2.3).
2801
+ //
2802
+ // Two layers, per the brief:
2803
+ // Layer 1 — a 1s client-side ticker computing now - createdAt with ZERO
2804
+ // network dependency. A dead agent, dead chat service or offline laptop
2805
+ // still ticks; that is the actual "never go silent" guarantee.
2806
+ // Layer 2 — milestone text refreshed off the messages.json poll, adaptive
2807
+ // 2s while pending / 10s idle. The milestone is content, not the
2808
+ // guarantee.
2809
+ //
2810
+ // The live status bubble (${LIVE_STATUS_BUBBLE_ID}) is a single element the
2811
+ // server may have rendered (mid-run reload) or the client creates on send.
2812
+ // Its data-created-at drives the ticker; data-progress-seq drives skew-free
2813
+ // stall detection (compare poll counts since progressSeq last changed, never
2814
+ // wall clocks).
2815
+ const inlineScript = `
2816
+ <script>
2817
+ (function() {
2818
+ var form = document.getElementById('send-form');
2819
+ var input = document.getElementById('message-input');
2820
+ var sendBtn = document.getElementById('send-btn');
2821
+ var attachBtn = document.getElementById('attach-btn');
2822
+ var fileInput = document.getElementById('file-input');
2823
+ var fileName = document.getElementById('file-name');
2824
+ var micBtn = document.getElementById('mic-btn');
2825
+ var container = document.getElementById('messages-container');
2826
+ var agentId = ${JSON.stringify(agentId)};
2827
+ var threadId = ${JSON.stringify(thread.id)};
2828
+ var messagesJsonUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages.json';
2829
+ var uploadUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages/upload';
2830
+
2831
+ // errorKind → label, generated from the server's single source of truth so
2832
+ // the two copies cannot drift.
2833
+ var ERROR_KIND_LABELS = ${JSON.stringify(ERROR_KIND_LABELS)};
2834
+ var DEFAULT_ERROR_LABEL = ${JSON.stringify(DEFAULT_ERROR_LABEL)};
2835
+ function errorKindLabel(kind) {
2836
+ return ERROR_KIND_LABELS[kind] || DEFAULT_ERROR_LABEL;
2837
+ }
2838
+
2839
+ // Milestone labels, likewise serialized from lib/progress-phases.ts.
2840
+ var PROGRESS_LABELS = ${JSON.stringify(PROGRESS_LABELS)};
2841
+
2842
+ // Stall-warning threshold — 120s in prod; e2e overrides to a tiny value.
2843
+ var STALL_WARN_AFTER_MS = ${stallWarnAfterMs};
2844
+ // Hard ceiling: 65min, mirrors lib/claim-ttl.ts DEFAULT_CLAIM_TTL_MS
2845
+ // (1hr DEFAULT_CLAUDE_TIMEOUT_MS + 5min CLAIM_TTL_BUFFER_MS). Only hard stop.
2846
+ var ABSOLUTE_MAX_MS = ${ABSOLUTE_MAX_MS};
2847
+
2848
+ var LIVE_STATUS_BUBBLE_ID = ${JSON.stringify(LIVE_STATUS_BUBBLE_ID)};
2849
+ var LIVE_STATUS_ELAPSED_ID = ${JSON.stringify(LIVE_STATUS_ELAPSED_ID)};
2850
+ var LIVE_STATUS_MILESTONE_ID = ${JSON.stringify(LIVE_STATUS_MILESTONE_ID)};
2851
+ var STALL_INDICATOR_CLASS = ${JSON.stringify(STALL_INDICATOR_CLASS)};
2852
+ var ASSISTANT_ROLE_CLASS = '${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}';
2853
+
2854
+ // Poll cadence.
2855
+ var POLL_PENDING_MS = 2000; // adaptive fast poll while a reply is pending
2856
+ var POLL_IDLE_MS = 10000; // slow idle poll for agent-initiated messages
2857
+
2858
+ // Rendered-message dedupe: every server bubble carries data-message-id;
2859
+ // seed the set from what's already in the DOM so polls never double-render.
2860
+ var renderedIds = {};
2861
+ Array.prototype.forEach.call(
2862
+ container.querySelectorAll('[data-message-id]'),
2863
+ function(el) { renderedIds[el.getAttribute('data-message-id')] = true; }
2864
+ );
2865
+
2866
+ var pollTimer = null;
2867
+ var tickerTimer = null;
2868
+ // Skew-free stall tracking: remember the progressSeq we last saw and the
2869
+ // wall-clock ms at which it last CHANGED (client clock only — never compared
2870
+ // against server timestamps).
2871
+ var lastProgressSeq = null;
2872
+ var lastProgressChangeMs = null;
2873
+
2874
+ function escHtml(str) {
2875
+ return String(str)
2876
+ .replace(/&/g, '&amp;')
2877
+ .replace(/</g, '&lt;')
2878
+ .replace(/>/g, '&gt;')
2879
+ .replace(/"/g, '&quot;');
2880
+ }
2881
+
2882
+ // Optimistic user bubble (pre-round-trip; body is plain text, so no markdown
2883
+ // — matches the server's white-space:pre-wrap user rendering). Assistant and
2884
+ // reloaded bubbles are NEVER built here: they come from server bubbleHtml.
2885
+ // \`file\` (VM-3.1) may be a picked file OR a mic-recorded Blob/File — for
2886
+ // an audio file this renders an inline <audio> player immediately, using a
2887
+ // local object URL (no round-trip needed to preview what was just
2888
+ // recorded); everything else still gets the plain filename badge.
2889
+ function addUserBubble(body, file) {
2890
+ var color = '#4f46e5';
2891
+ var attachmentHtml = '';
2892
+ if (file) {
2893
+ if (file.type && file.type.indexOf('audio/') === 0) {
2894
+ attachmentHtml = '<audio controls style="display:block;margin-top:8px;max-width:100%" src="' + URL.createObjectURL(file) + '"></audio>';
2895
+ } else {
2896
+ attachmentHtml = '<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ' + escHtml(file.name) + '</div>';
2897
+ }
2898
+ }
2899
+ var bubble = document.createElement('div');
2900
+ bubble.className = '${CHAT_BUBBLE_CLASS} ${CHAT_BUBBLE_CLASS}--user';
2901
+ bubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
2902
+ + '<div style="font-size:11px;font-weight:600;color:' + color + ';margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em">user</div>'
2903
+ + '<div style="font-size:14px;white-space:pre-wrap;color:' + color + '">' + escHtml(body) + '</div>'
2904
+ + attachmentHtml
2905
+ + '</div>';
2906
+ insertBeforeLiveStatus(bubble);
2907
+ container.scrollTop = container.scrollHeight;
2908
+ return bubble;
2909
+ }
2910
+
2911
+ // Insert a bubble before the live-status bubble (so status stays at the
2912
+ // bottom) or append if there's no live-status bubble.
2913
+ function insertBeforeLiveStatus(node) {
2914
+ var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
2915
+ if (live) container.insertBefore(node, live);
2916
+ else container.appendChild(node);
2917
+ }
2918
+
2919
+ // Insert a server-rendered bubbleHtml string, deduped by id.
2920
+ function renderServerBubble(m) {
2921
+ if (!m || !m.id || renderedIds[m.id]) return false;
2922
+ renderedIds[m.id] = true;
2923
+ if (!m.bubbleHtml) return false;
2924
+ var tpl = document.createElement('template');
2925
+ tpl.innerHTML = m.bubbleHtml.trim();
2926
+ var node = tpl.content.firstChild;
2927
+ if (node) insertBeforeLiveStatus(node);
2928
+ return true;
2929
+ }
2930
+
2931
+ // True when the user is within ~80px of the bottom, i.e. hasn't scrolled up
2932
+ // to read history. Polls only auto-scroll in that case.
2933
+ function isNearBottom() {
2934
+ return container.scrollHeight - container.scrollTop - container.clientHeight < 80;
2935
+ }
2936
+
2937
+ // Ensure the live status bubble exists (creating it on the client when the
2938
+ // user just sent a message and the server hasn't rendered one). Scrolls to
2939
+ // the bottom unless noScroll is true (poll with the user scrolled up).
2940
+ function ensureLiveStatusBubble(createdAtMs, noScroll) {
2941
+ var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
2942
+ if (live) return live;
2943
+ live = document.createElement('div');
2944
+ live.id = LIVE_STATUS_BUBBLE_ID;
2945
+ live.className = ASSISTANT_ROLE_CLASS;
2946
+ live.setAttribute('data-created-at', String(createdAtMs));
2947
+ live.setAttribute('data-progress-seq', '0');
2948
+ live.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
2949
+ + '<div style="font-size:14px;color:#166534;font-style:italic">'
2950
+ + '<span id="' + LIVE_STATUS_MILESTONE_ID + '"></span> '
2951
+ + '<span id="' + LIVE_STATUS_ELAPSED_ID + '">working… (0s)</span>'
2952
+ + '</div></div>';
2953
+ container.appendChild(live);
2954
+ if (!noScroll) container.scrollTop = container.scrollHeight;
2955
+ return live;
2956
+ }
2957
+
2958
+ function removeLiveStatusBubble() {
2959
+ var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
2960
+ if (live && live.parentNode) live.parentNode.removeChild(live);
2961
+ lastProgressSeq = null;
2962
+ lastProgressChangeMs = null;
2963
+ }
2964
+
2965
+ // Layer 1: the ZERO-network ticker. Runs every 1s off the live bubble's
2966
+ // data-created-at. Also applies the stall class when progressSeq has been
2967
+ // frozen past STALL_WARN_AFTER_MS, and hard-stops at ABSOLUTE_MAX_MS.
2968
+ function tick() {
2969
+ var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
2970
+ if (!live) return;
2971
+ var createdAtMs = Number(live.getAttribute('data-created-at')) || Date.now();
2972
+ var elapsedMs = Date.now() - createdAtMs;
2973
+ var elapsedSec = Math.max(0, Math.round(elapsedMs / 1000));
2974
+ var elapsedEl = document.getElementById(LIVE_STATUS_ELAPSED_ID);
2975
+ if (elapsedEl) elapsedEl.textContent = 'working… (' + elapsedSec + 's)';
2976
+
2977
+ // Stall detection is time-since-progressSeq-last-changed, on the client
2978
+ // clock only. Seed the change time on first sight.
2979
+ if (lastProgressChangeMs === null) lastProgressChangeMs = Date.now();
2980
+ var stalledFor = Date.now() - lastProgressChangeMs;
2981
+ if (stalledFor >= STALL_WARN_AFTER_MS) {
2982
+ live.classList.add(STALL_INDICATOR_CLASS);
2983
+ } else {
2984
+ live.classList.remove(STALL_INDICATOR_CLASS);
2985
+ }
2986
+
2987
+ if (elapsedMs >= ABSOLUTE_MAX_MS) {
2988
+ // Only hard stop: replace the live bubble with a terminal error and
2989
+ // stop everything.
2990
+ stopAll();
2991
+ removeLiveStatusBubble();
2992
+ var errBubble = document.createElement('div');
2993
+ errBubble.className = ASSISTANT_ROLE_CLASS;
2994
+ errBubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
2995
+ + '<div style="margin-top:0;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600">Request timed out. Please try again.</div>'
2996
+ + '</div>';
2997
+ container.appendChild(errBubble);
2998
+ enableSend();
2999
+ }
3000
+ }
3001
+
3002
+ function startTicker() {
3003
+ if (tickerTimer) return;
3004
+ tick();
3005
+ tickerTimer = setInterval(tick, 1000);
3006
+ }
3007
+
3008
+ function updateMilestone(phase) {
3009
+ var el = document.getElementById(LIVE_STATUS_MILESTONE_ID);
3010
+ if (!el) return;
3011
+ el.textContent = phase && PROGRESS_LABELS[phase] ? PROGRESS_LABELS[phase] : '';
3012
+ }
3013
+
3014
+ function stopPolling() {
3015
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
3016
+ }
3017
+ function stopTicker() {
3018
+ if (tickerTimer) { clearInterval(tickerTimer); tickerTimer = null; }
3019
+ }
3020
+ function stopAll() { stopPolling(); stopTicker(); }
3021
+
3022
+ function enableSend() {
3023
+ sendBtn.disabled = false;
3024
+ sendBtn.textContent = 'Send';
3025
+ if (micBtn) micBtn.disabled = false;
3026
+ }
3027
+
3028
+ // Adaptive poll: fast while a reply is pending, slow while idle (so
3029
+ // agent-initiated messages still appear without a reload).
3030
+ function schedulePoll(delay) {
3031
+ stopPolling();
3032
+ pollTimer = setTimeout(poll, delay);
3033
+ }
3034
+
3035
+ function poll() {
3036
+ fetch(messagesJsonUrl)
3037
+ .then(function(r) { return r.json(); })
3038
+ .then(function(data) {
3039
+ var msgs = data.messages || [];
3040
+
3041
+ // Capture before rendering new bubbles grows scrollHeight.
3042
+ var stickToBottom = isNearBottom();
3043
+
3044
+ // Render every new (un-rendered) message from the server, in order —
3045
+ // fixes "only the last reply appears" and "agent-initiated messages
3046
+ // need a reload". Assistant/user/system all go through server
3047
+ // bubbleHtml, which already includes any Retry button (CFB-2.4) since
3048
+ // the server computes retryBody per-message; wire up any new buttons
3049
+ // after insertion (see wireRetryButtons below).
3050
+ for (var i = 0; i < msgs.length; i++) {
3051
+ renderServerBubble(msgs[i]);
3052
+ }
3053
+ wireRetryButtons();
3054
+
3055
+ // Determine whether a reply is still pending (last message is an
3056
+ // unreplied user message).
3057
+ var last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
3058
+ var pending = last && last.role === 'user' && !last.repliedAt ? last : null;
3059
+
3060
+ if (pending) {
3061
+ // Keep the live status bubble present and in sync.
3062
+ var createdAtMs = pending.createdAt ? new Date(pending.createdAt).getTime() : Date.now();
3063
+ var live = ensureLiveStatusBubble(createdAtMs, !stickToBottom);
3064
+ live.setAttribute('data-created-at', String(createdAtMs));
3065
+ startTicker();
3066
+ updateMilestone(pending.progressPhase);
3067
+
3068
+ // Skew-free stall tracking: reset the change clock only when
3069
+ // progressSeq actually advances.
3070
+ var seq = typeof pending.progressSeq === 'number' ? pending.progressSeq : 0;
3071
+ if (lastProgressSeq === null || seq !== lastProgressSeq) {
3072
+ lastProgressSeq = seq;
3073
+ lastProgressChangeMs = Date.now();
3074
+ }
3075
+ live.setAttribute('data-progress-seq', String(seq));
3076
+
3077
+ if (stickToBottom) container.scrollTop = container.scrollHeight;
3078
+ schedulePoll(POLL_PENDING_MS);
3079
+ } else {
3080
+ // No reply pending: reply landed (or nothing in flight). Tear down
3081
+ // the live status bubble and re-enable send, then keep a slow idle
3082
+ // poll running so agent-initiated messages still show up live.
3083
+ removeLiveStatusBubble();
3084
+ stopTicker();
3085
+ enableSend();
3086
+ if (stickToBottom) container.scrollTop = container.scrollHeight;
3087
+ schedulePoll(POLL_IDLE_MS);
3088
+ }
3089
+ })
3090
+ .catch(function() {
3091
+ // Network error — Layer 1 ticker keeps going untouched; just retry the
3092
+ // poll on the pending cadence.
3093
+ schedulePoll(POLL_PENDING_MS);
3094
+ });
3095
+ }
3096
+
3097
+ // Attach-file button opens the hidden file input; show the chosen name.
3098
+ if (attachBtn && fileInput) {
3099
+ attachBtn.addEventListener('click', function() {
3100
+ fileInput.click();
3101
+ });
3102
+ fileInput.addEventListener('change', function() {
3103
+ var f = fileInput.files && fileInput.files[0];
3104
+ fileName.textContent = f ? ('📎 ' + f.name) : '';
3105
+ });
3106
+ }
3107
+
3108
+ // Mic-record button (VM-3.1): records via MediaRecorder and submits the
3109
+ // resulting Blob through the SAME sendText()/upload flow as a picked file
3110
+ // — no separate endpoint. Click once to start, click again (or the button
3111
+ // relabels to a stop icon) to stop and send. Candidate MIME types are
3112
+ // ordered by preference; attachment-validation.ts's allowlist accepts all
3113
+ // three so whichever the browser actually supports still uploads cleanly.
3114
+ var MIC_MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg', 'audio/mp4'];
3115
+ var mediaRecorder = null;
3116
+ var micStream = null;
3117
+ var recordedChunks = [];
3118
+
3119
+ function micSupported() {
3120
+ return !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
3121
+ }
3122
+
3123
+ function stopMicStream() {
3124
+ if (micStream) {
3125
+ micStream.getTracks().forEach(function(t) { t.stop(); });
3126
+ micStream = null;
3127
+ }
3128
+ }
3129
+
3130
+ function setMicRecordingUI(recording) {
3131
+ if (!micBtn) return;
3132
+ micBtn.textContent = recording ? '⏹' : '🎤';
3133
+ micBtn.classList.toggle('chat-mic-recording', recording);
3134
+ micBtn.setAttribute('aria-label', recording ? 'Stop recording' : 'Record a voice message');
3135
+ }
3136
+
3137
+ if (micBtn) {
3138
+ if (!micSupported()) {
3139
+ // Graceful degradation: no MediaRecorder/getUserMedia in this browser
3140
+ // (or non-HTTPS context) — hide the button rather than offer a dead
3141
+ // control.
3142
+ micBtn.style.display = 'none';
3143
+ } else {
3144
+ micBtn.addEventListener('click', function() {
3145
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
3146
+ mediaRecorder.stop();
3147
+ return;
3148
+ }
3149
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
3150
+ micStream = stream;
3151
+ recordedChunks = [];
3152
+ var mimeType = '';
3153
+ for (var i = 0; i < MIC_MIME_CANDIDATES.length; i++) {
3154
+ if (window.MediaRecorder.isTypeSupported && window.MediaRecorder.isTypeSupported(MIC_MIME_CANDIDATES[i])) {
3155
+ mimeType = MIC_MIME_CANDIDATES[i];
3156
+ break;
3157
+ }
3158
+ }
3159
+ try {
3160
+ mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType: mimeType }) : new MediaRecorder(stream);
3161
+ } catch (e) {
3162
+ stopMicStream();
3163
+ return;
3164
+ }
3165
+ mediaRecorder.addEventListener('dataavailable', function(e) {
3166
+ if (e.data && e.data.size > 0) recordedChunks.push(e.data);
3167
+ });
3168
+ mediaRecorder.addEventListener('stop', function() {
3169
+ stopMicStream();
3170
+ setMicRecordingUI(false);
3171
+ var rawType = mediaRecorder.mimeType || mimeType || 'audio/webm';
3172
+ // Chromium reports audio-only recordings as video/webm; normalize so
3173
+ // the File is never non-audio/*.
3174
+ var blobType = rawType.indexOf('audio/') === 0 ? rawType : rawType.replace(/^[^;/]*/, 'audio');
3175
+ var blob = new Blob(recordedChunks, { type: blobType });
3176
+ recordedChunks = [];
3177
+ if (blob.size === 0) return;
3178
+ var ext = blobType.indexOf('ogg') !== -1 ? 'ogg' : blobType.indexOf('mp4') !== -1 ? 'm4a' : 'webm';
3179
+ var file = new File([blob], 'recording-' + Date.now() + '.' + ext, { type: blobType });
3180
+ sendText('', file);
3181
+ });
3182
+ mediaRecorder.start();
3183
+ setMicRecordingUI(true);
3184
+ }).catch(function() {
3185
+ // Permission denied, no mic, or blocked context — no-op; the
3186
+ // button simply stays in its idle state.
3187
+ });
3188
+ });
3189
+ }
3190
+ }
3191
+
3192
+ function clearFile() {
3193
+ if (fileInput) fileInput.value = '';
3194
+ if (fileName) fileName.textContent = '';
3195
+ }
3196
+
3197
+ function sendText(text, file) {
3198
+ if (!text && !file) return;
3199
+
3200
+ // Disable send button (and mic — no recording mid-send)
3201
+ sendBtn.disabled = true;
3202
+ sendBtn.textContent = 'Sending…';
3203
+ if (micBtn) micBtn.disabled = true;
3204
+
3205
+ // Build multipart body before clearing the inputs
3206
+ var fd = new FormData();
3207
+ fd.append('body', text);
3208
+ if (file) fd.append('file', file);
3209
+
3210
+ // Add user bubble optimistically (with attachment preview if present).
3211
+ addUserBubble(text, file);
3212
+
3213
+ // Show the live status bubble + start the ticker immediately — Layer 1
3214
+ // begins ticking with zero network dependency.
3215
+ ensureLiveStatusBubble(Date.now());
3216
+ lastProgressSeq = null;
3217
+ lastProgressChangeMs = Date.now();
3218
+ startTicker();
3219
+
3220
+ // POST multipart to the upload endpoint
3221
+ fetch(uploadUrl, {
3222
+ method: 'POST',
3223
+ body: fd
3224
+ }).then(function(r) {
3225
+ if (!r.ok) {
3226
+ return r.json().then(function(data) {
3227
+ stopAll();
3228
+ removeLiveStatusBubble();
3229
+ var errBubble = document.createElement('div');
3230
+ errBubble.className = ASSISTANT_ROLE_CLASS;
3231
+ errBubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
3232
+ + '<div style="margin-top:0;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600">' + escHtml((data && data.error) || 'Upload failed.') + '</div>'
3233
+ + '</div>';
3234
+ container.appendChild(errBubble);
3235
+ enableSend();
3236
+ });
3237
+ } else {
3238
+ // Success: parse response and seed renderedIds to prevent duplicate rendering
3239
+ return r.json().then(function(data) {
3240
+ if (data && data.message && data.message.id) {
3241
+ renderedIds[data.message.id] = true;
3242
+ }
3243
+ });
3244
+ }
3245
+ }).catch(function() {
3246
+ // POST failed — Layer 1 ticker keeps going; poll loop still starts.
3247
+ });
3248
+
3249
+ // Start the fast poll loop.
3250
+ schedulePoll(POLL_PENDING_MS);
3251
+ }
3252
+
3253
+ form.addEventListener('submit', function(e) {
3254
+ e.preventDefault();
3255
+ var text = input.value.trim();
3256
+ var file = fileInput && fileInput.files && fileInput.files[0];
3257
+ if (!text && !file) return;
3258
+ input.value = '';
3259
+ clearFile();
3260
+ sendText(text, file);
3261
+ });
3262
+
3263
+ // Retry buttons (CFB-2.4) resend the exact user message body that
3264
+ // triggered the errored/cancelled/incomplete/stalled reply — rendered
3265
+ // server-side as a data attribute on each button (see
3266
+ // renderChatMessageBubble). Buttons can arrive either on initial page load
3267
+ // or later via renderServerBubble during a poll (which dedupes by
3268
+ // data-message-id, so a given button is only ever inserted into the DOM
3269
+ // once); re-running this after every render pass is therefore safe and
3270
+ // never double-wires a button.
3271
+ function wireRetryButtons() {
3272
+ document.querySelectorAll('.chat-retry-btn').forEach(function(btn) {
3273
+ if (btn.dataset.wired) return;
3274
+ btn.dataset.wired = '1';
3275
+ btn.addEventListener('click', function() {
3276
+ if (btn.disabled) return;
3277
+ var body = btn.getAttribute('data-retry-body');
3278
+ if (!body) return;
3279
+ btn.disabled = true;
3280
+ sendText(body, null);
3281
+ });
3282
+ });
3283
+ }
3284
+ wireRetryButtons();
3285
+
3286
+ // On load: if the server rendered a live status bubble (mid-run reload),
3287
+ // start the ticker immediately and begin polling on the pending cadence.
3288
+ // Otherwise begin a slow idle poll so agent-initiated messages appear live.
3289
+ var serverLive = document.getElementById(LIVE_STATUS_BUBBLE_ID);
3290
+ if (serverLive) {
3291
+ var seedSeq = Number(serverLive.getAttribute('data-progress-seq'));
3292
+ lastProgressSeq = isNaN(seedSeq) ? 0 : seedSeq;
3293
+ lastProgressChangeMs = Date.now();
3294
+ startTicker();
3295
+ schedulePoll(POLL_PENDING_MS);
3296
+ } else {
3297
+ schedulePoll(POLL_IDLE_MS);
3298
+ }
3299
+
3300
+ // Scroll to bottom on load
3301
+ container.scrollTop = container.scrollHeight;
3302
+ })();
3303
+ </script>`;
3304
+
3305
+ // Standalone drawer + keyboard-inset script (CFB-3.2). Deliberately emitted
3306
+ // as its OWN <script> block — NOT inside the progress IIFE above — so the two
3307
+ // concerns don't fight the ongoing live-progress rewrite. Three tiny jobs:
3308
+ // 1. visualViewport → --kb-inset: on iOS the on-screen keyboard shrinks the
3309
+ // VISUAL viewport but leaves the LAYOUT viewport (and dvh) alone, sliding
3310
+ // the sticky composer under the keyboard. Setting --kb-inset to the
3311
+ // occluded height lets CSS pad the composer back into view. Guarded by
3312
+ // `if (!vv) return` — no speculative fallback for browsers without it.
3313
+ // 2. aria-expanded sync on the hamburger, mirroring the toolbar idiom.
3314
+ // 3. Escape closes the drawer (a bare checkbox can't listen for keys).
3315
+ const drawerScript = `
3316
+ <script>
3317
+ (function() {
3318
+ var vv = window.visualViewport;
3319
+ if (!vv) return;
3320
+ var root = document.documentElement;
3321
+ function updateKbInset() {
3322
+ // Occluded height = layout viewport height minus the visual viewport
3323
+ // height (plus its offset). Clamp to >= 0. On Android where dvh already
3324
+ // shrinks the layout this stays ~0, which is correct.
3325
+ var occluded = window.innerHeight - vv.height - vv.offsetTop;
3326
+ root.style.setProperty('--kb-inset', (occluded > 0 ? occluded : 0) + 'px');
3327
+ }
3328
+ vv.addEventListener('resize', updateKbInset);
3329
+ vv.addEventListener('scroll', updateKbInset);
3330
+ updateKbInset();
3331
+ })();
3332
+ (function() {
3333
+ var toggle = document.getElementById('chat-drawer-toggle');
3334
+ if (!toggle) return;
3335
+ var burger = document.querySelector('.chat-drawer-hamburger');
3336
+ function syncAria() {
3337
+ if (burger) burger.setAttribute('aria-expanded', toggle.checked ? 'true' : 'false');
3338
+ }
3339
+ toggle.addEventListener('change', syncAria);
3340
+ document.addEventListener('keydown', function(e) {
3341
+ if ((e.key === 'Escape' || e.key === 'Esc') && toggle.checked) {
3342
+ toggle.checked = false;
3343
+ syncAria();
3344
+ }
3345
+ });
3346
+ })();
3347
+ </script>`;
3348
+
3349
+ // Thread list sidebar pane
3350
+ // NOTE: no inline font-size here — an inline style would beat the
3351
+ // .form-input mobile @media override by specificity and reintroduce the
3352
+ // iOS Safari zoom-on-focus defect. Sizing is handled entirely via the
3353
+ // .chat-sidebar-new-thread-input class below.
3354
+ const newThreadForm = `
3355
+ <form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads" style="margin-bottom:12px">
3356
+ <div class="form-row" style="gap:6px">
3357
+ <input type="text" name="title" class="form-input chat-sidebar-new-thread-input" placeholder="New thread title…">
3358
+ <button type="submit" class="btn btn-primary" style="white-space:nowrap;padding:6px 10px">New Thread</button>
3359
+ </div>
3360
+ </form>`;
3361
+
3362
+ const threadLinks = threads
3363
+ ? threads.length === 0
3364
+ ? `<div class="empty-state" style="padding:12px">No threads.</div>`
3365
+ : threads
3366
+ .map((t) => {
3367
+ const tTitle = escapeHtml(t.title ?? "Untitled");
3368
+ const isActive = t.id === threadId;
3369
+ return `<a href="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(t.id)}" class="thread-pane-link${isActive ? " active" : ""}">${tTitle}</a>`;
3370
+ })
3371
+ .join("\n")
3372
+ : "";
3373
+
3374
+ const sidebar =
3375
+ threads !== null
3376
+ ? `<div id="chat-thread-sidebar-panel" class="card chat-thread-sidebar" style="min-width:220px;max-width:280px;flex-shrink:0">
3377
+ <div class="card-title">Threads</div>
3378
+ ${newThreadForm}
3379
+ <div class="thread-pane-list">
3380
+ ${threadLinks}
3381
+ </div>
3382
+ </div>`
3383
+ : "";
3384
+
3385
+ // Drawer chrome (CFB-3.2). Only render the CSS-only toggle + scrim + hamburger
3386
+ // when there's actually a sidebar to reveal — in degraded/no-threads mode the
3387
+ // sidebar ternary yields "" and the drawer would be an empty shell. DOM order
3388
+ // is load-bearing: the checkbox must precede the sidebar and scrim so the
3389
+ // `~` sibling combinator can reach them from `:checked`.
3390
+ const drawerToggle = sidebar
3391
+ ? `<input type="checkbox" id="chat-drawer-toggle" class="chat-drawer-toggle" aria-hidden="true">`
3392
+ : "";
3393
+ const drawerScrim = sidebar
3394
+ ? `<label for="chat-drawer-toggle" class="chat-drawer-scrim" aria-label="Close thread list"></label>`
3395
+ : "";
3396
+ // aria-controls references the revealed panel's own id (not the checkbox) —
3397
+ // matches the aria-controls="vos-nav-content" pattern in lib/web/toolbar.ts.
3398
+ const drawerHamburger = sidebar
3399
+ ? `<label for="chat-drawer-toggle" class="chat-drawer-hamburger" role="button" tabindex="0" aria-label="Toggle thread list" aria-expanded="false" aria-controls="chat-thread-sidebar-panel">☰</label>`
3400
+ : "";
3401
+
3402
+ return renderAdminPage({
3403
+ title: `${rawTitle} - Shipwright Admin`,
3404
+ // NOTE: chatThreadStyles must be concatenated before chatPageStyles here —
3405
+ // chatPageStyles's mobile @media (max-width:640px) override for
3406
+ // .chat-bubble-inner has the same specificity as chatThreadStyles's
3407
+ // unconditional base rule, so whichever is concatenated *last* wins the
3408
+ // cascade. Putting chatPageStyles last ensures the mobile override
3409
+ // actually takes effect at ≤640px instead of being silently shadowed.
3410
+ extraStyles: `${threadPaneStyles}${chatThreadStyles}${chatPageStyles}
3411
+ `,
3412
+ body: `${renderAdminToolbar(userName, activePath)}
3413
+ <div class="vos-page chat-thread-page">
3414
+ <div class="page-header chat-thread-header">
3415
+ <div>
3416
+ <div class="chat-thread-header-top" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
3417
+ ${drawerHamburger}
3418
+ <a href="/admin/chat?agentId=${safeAgentId}" class="btn btn-secondary">&larr; Back to threads</a>
3419
+ </div>
3420
+ <h1 class="page-title">${title}</h1>
3421
+ <div class="chat-thread-meta" style="font-size:12px;color:#9ca3af;margin-top:4px">Thread <span class="mono">${safeThreadId}</span></div>
3422
+ ${
3423
+ stats &&
3424
+ (
3425
+ stats.totalInputTokens > 0 ||
3426
+ stats.totalOutputTokens > 0 ||
3427
+ stats.totalCostUsd > 0
3428
+ )
3429
+ ? `<div class="chat-thread-stats" style="font-size:12px;color:#6b7280;margin-top:4px">${escapeHtml(`${formatTokenCount(stats.totalInputTokens)} in / ${formatTokenCount(stats.totalOutputTokens)} out | $${stats.totalCostUsd.toFixed(4)}`)}</div>`
3430
+ : ""
3431
+ }
3432
+ ${threadActionsDetails}
3433
+ ${renderPushToggle({
3434
+ pushEnabled: opts?.pushEnabled ?? false,
3435
+ vapidPublicKey: opts?.vapidPublicKey ?? "",
3436
+ agentId,
3437
+ threadId,
3438
+ })}
3439
+ </div>
3440
+ </div>
3441
+ <div class="chat-thread-layout" style="display:flex;gap:24px;flex:1;min-height:0;margin-top:16px">
3442
+ ${drawerToggle}
3443
+ ${sidebar}
3444
+ ${drawerScrim}
3445
+ <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
3446
+ <!-- Messages area (scrollable) -->
3447
+ <div id="messages-container" class="chat-messages-container">
3448
+ ${messageBubbles}
3449
+ ${liveStatusBubble}
3450
+ ${emptyState}
3451
+ </div>
3452
+
3453
+ <!-- Send form -->
3454
+ <form id="send-form" enctype="multipart/form-data" class="chat-composer-form">
3455
+ <div class="chat-composer-row">
3456
+ <textarea
3457
+ id="message-input"
3458
+ name="body"
3459
+ rows="3"
3460
+ placeholder="Type a message..."
3461
+ class="chat-message-input"
3462
+ ></textarea>
3463
+ <input type="file" id="file-input" name="file" style="display:none" accept="text/*,image/*,application/pdf,application/json">
3464
+ <button
3465
+ type="button"
3466
+ id="mic-btn"
3467
+ class="btn btn-secondary chat-composer-btn chat-composer-btn--mic"
3468
+ aria-label="Record a voice message"
3469
+ title="Record a voice message"
3470
+ >🎤</button>
3471
+ <button
3472
+ type="button"
3473
+ id="attach-btn"
3474
+ class="btn btn-secondary chat-composer-btn chat-composer-btn--attach"
3475
+ >Attach file</button>
3476
+ <button
3477
+ type="submit"
3478
+ id="send-btn"
3479
+ class="btn btn-primary chat-composer-btn chat-composer-btn--send"
3480
+ >Send</button>
3481
+ </div>
3482
+ <div id="file-name" style="font-size:12px;color:#6b7280;margin-top:6px;min-height:16px"></div>
3483
+ </form>
3484
+ </div>
3485
+ </div>
3486
+ </div>`,
3487
+ bodyEnd: `${inlineScript}\n${drawerScript}`,
3488
+ });
3489
+ }