codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,873 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ import { api, escapeHtml, flash, getUrlParams, resolvePath, updateUrlParams, } from "./utils.js";
3
+ import { subscribe } from "./bus.js";
4
+ import { isRepoHealthy } from "./health.js";
5
+ import { preserveScroll } from "./preserve.js";
6
+ import { createSmartRefresh } from "./smartRefresh.js";
7
+ let bellInitialized = false;
8
+ let messagesInitialized = false;
9
+ let activeRunId = null;
10
+ let selectedRunId = null;
11
+ const MESSAGE_REFRESH_REASONS = ["initial", "background", "manual"];
12
+ const threadsEl = document.getElementById("messages-thread-list");
13
+ const detailEl = document.getElementById("messages-thread-detail");
14
+ const layoutEl = document.querySelector(".messages-layout");
15
+ const backBtn = document.getElementById("messages-back-btn");
16
+ const refreshEl = document.getElementById("messages-refresh");
17
+ const replyBodyEl = document.getElementById("messages-reply-body");
18
+ const replyFilesEl = document.getElementById("messages-reply-files");
19
+ const replySendEl = document.getElementById("messages-reply-send");
20
+ let threadListRefreshCount = 0;
21
+ let threadDetailRefreshCount = 0;
22
+ function isMobileViewport() {
23
+ return window.innerWidth <= 640;
24
+ }
25
+ function showThreadList() {
26
+ layoutEl?.classList.remove("viewing-detail");
27
+ }
28
+ function showThreadDetail() {
29
+ if (isMobileViewport()) {
30
+ layoutEl?.classList.add("viewing-detail");
31
+ }
32
+ }
33
+ function setThreadListRefreshing(active) {
34
+ if (!threadsEl)
35
+ return;
36
+ threadListRefreshCount = Math.max(0, threadListRefreshCount + (active ? 1 : -1));
37
+ threadsEl.classList.toggle("refreshing", threadListRefreshCount > 0);
38
+ }
39
+ function setThreadDetailRefreshing(active) {
40
+ if (!detailEl)
41
+ return;
42
+ threadDetailRefreshCount = Math.max(0, threadDetailRefreshCount + (active ? 1 : -1));
43
+ detailEl.classList.toggle("refreshing", threadDetailRefreshCount > 0);
44
+ }
45
+ function formatTimestamp(ts) {
46
+ if (!ts)
47
+ return "–";
48
+ const date = new Date(ts);
49
+ if (Number.isNaN(date.getTime()))
50
+ return ts;
51
+ return date.toLocaleString();
52
+ }
53
+ function setBadge(count) {
54
+ const badge = document.getElementById("tab-badge-inbox");
55
+ if (!badge)
56
+ return;
57
+ if (count > 0) {
58
+ badge.textContent = String(count);
59
+ badge.classList.remove("hidden");
60
+ }
61
+ else {
62
+ badge.textContent = "";
63
+ badge.classList.add("hidden");
64
+ }
65
+ }
66
+ export async function refreshBell() {
67
+ if (!isRepoHealthy()) {
68
+ activeRunId = null;
69
+ setBadge(0);
70
+ return;
71
+ }
72
+ try {
73
+ const res = (await api("/api/messages/active"));
74
+ if (res?.active && res.run_id) {
75
+ activeRunId = res.run_id;
76
+ setBadge(1);
77
+ }
78
+ else {
79
+ activeRunId = null;
80
+ setBadge(0);
81
+ }
82
+ }
83
+ catch (_err) {
84
+ // Best-effort.
85
+ activeRunId = null;
86
+ setBadge(0);
87
+ }
88
+ }
89
+ export function initMessageBell() {
90
+ if (bellInitialized)
91
+ return;
92
+ bellInitialized = true;
93
+ // Cheap polling. (The repo shell already does other polling; keep this light.)
94
+ refreshBell();
95
+ window.setInterval(() => {
96
+ if (document.hidden)
97
+ return;
98
+ if (!isRepoHealthy())
99
+ return;
100
+ refreshBell();
101
+ }, 15000);
102
+ subscribe("repo:health", (payload) => {
103
+ const status = payload?.status || "";
104
+ if (status === "ok" || status === "degraded") {
105
+ void refreshBell();
106
+ }
107
+ });
108
+ }
109
+ function formatRelativeTime(ts) {
110
+ if (!ts)
111
+ return "";
112
+ const date = new Date(ts);
113
+ if (Number.isNaN(date.getTime()))
114
+ return "";
115
+ const now = new Date();
116
+ const diffMs = now.getTime() - date.getTime();
117
+ const diffSecs = Math.floor(diffMs / 1000);
118
+ if (diffSecs < 60)
119
+ return "just now";
120
+ const diffMins = Math.floor(diffSecs / 60);
121
+ if (diffMins < 60)
122
+ return `${diffMins}m ago`;
123
+ const diffHours = Math.floor(diffMins / 60);
124
+ if (diffHours < 24)
125
+ return `${diffHours}h ago`;
126
+ const diffDays = Math.floor(diffHours / 24);
127
+ if (diffDays < 7)
128
+ return `${diffDays}d ago`;
129
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
130
+ }
131
+ function getStatusPillClass(status) {
132
+ switch (status) {
133
+ case "paused":
134
+ return "pill-action";
135
+ case "running":
136
+ case "pending":
137
+ return "pill-success";
138
+ case "completed":
139
+ return "pill-idle";
140
+ case "failed":
141
+ case "stopped":
142
+ return "pill-error";
143
+ default:
144
+ return "pill-idle";
145
+ }
146
+ }
147
+ function renderThreadItem(thread) {
148
+ const latestDispatch = thread.latest?.dispatch;
149
+ const isHandoff = latestDispatch?.is_handoff || latestDispatch?.mode === "pause";
150
+ const title = latestDispatch?.title || (isHandoff ? "Handoff" : "Dispatch");
151
+ const subtitle = latestDispatch?.body ? latestDispatch.body.slice(0, 120) : "";
152
+ const isPaused = thread.status === "paused";
153
+ const isActive = selectedRunId && thread.run_id === selectedRunId;
154
+ // Only show action indicator if there's an unreplied handoff (pause)
155
+ // Compare dispatch_seq vs reply_seq to check if user has responded
156
+ const ticketState = thread.ticket_state;
157
+ const dispatchSeq = ticketState?.dispatch_seq ?? 0;
158
+ const replySeq = ticketState?.reply_seq ?? 0;
159
+ const hasUnrepliedHandoff = isPaused && (dispatchSeq > replySeq || (isHandoff && replySeq === 0));
160
+ const indicator = hasUnrepliedHandoff ? `<span class="messages-thread-indicator" title="Action required"></span>` : "";
161
+ const dispatches = thread.dispatch_count ?? 0;
162
+ const replies = thread.reply_count ?? 0;
163
+ // Format timestamp for last dispatch
164
+ const lastTs = thread.latest?.created_at;
165
+ const timeAgo = formatRelativeTime(lastTs);
166
+ // Status badge
167
+ const status = thread.status || "idle";
168
+ const statusClass = getStatusPillClass(status);
169
+ const statusLabel = status === "paused" && hasUnrepliedHandoff ? "action" : status;
170
+ // Build meta line with timestamp
171
+ const countPart = `${dispatches} dispatch${dispatches !== 1 ? "es" : ""} · ${replies} repl${replies !== 1 ? "ies" : "y"}`;
172
+ return `
173
+ <button class="messages-thread${isActive ? " active" : ""}" data-run-id="${escapeHtml(thread.run_id)}">
174
+ <div class="messages-thread-header">
175
+ <div class="messages-thread-title">${indicator}${escapeHtml(title)}</div>
176
+ <span class="pill pill-small ${statusClass}">${escapeHtml(statusLabel)}</span>
177
+ </div>
178
+ <div class="messages-thread-subtitle muted">${escapeHtml(subtitle)}</div>
179
+ <div class="messages-thread-meta-line">
180
+ <span class="messages-thread-counts">${escapeHtml(countPart)}</span>
181
+ ${timeAgo ? `<span class="messages-thread-time">${escapeHtml(timeAgo)}</span>` : ""}
182
+ </div>
183
+ </button>
184
+ `;
185
+ }
186
+ function syncSelectedThread() {
187
+ if (!threadsEl)
188
+ return;
189
+ const buttons = threadsEl.querySelectorAll(".messages-thread");
190
+ buttons.forEach((btn) => {
191
+ const runId = btn.dataset.runId || "";
192
+ btn.classList.toggle("active", Boolean(runId) && runId === selectedRunId);
193
+ });
194
+ }
195
+ function threadListSignature(conversations) {
196
+ return conversations
197
+ .map((thread) => {
198
+ const latest = thread.latest;
199
+ const dispatch = latest?.dispatch;
200
+ const ticketState = thread.ticket_state;
201
+ return [
202
+ thread.run_id,
203
+ thread.status ?? "",
204
+ latest?.seq ?? "",
205
+ latest?.created_at ?? "",
206
+ dispatch?.mode ?? "",
207
+ dispatch?.is_handoff ? "1" : "0",
208
+ thread.dispatch_count ?? "",
209
+ thread.reply_count ?? "",
210
+ ticketState?.dispatch_seq ?? "",
211
+ ticketState?.reply_seq ?? "",
212
+ ticketState?.status ?? "",
213
+ ].join("|");
214
+ })
215
+ .join("::");
216
+ }
217
+ function threadDetailSignature(detail) {
218
+ const dispatches = detail.dispatch_history || [];
219
+ const replies = detail.reply_history || [];
220
+ const maxDispatchSeq = dispatches.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
221
+ const maxReplySeq = replies.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
222
+ const lastDispatchAt = dispatches.find((entry) => entry.seq === maxDispatchSeq)?.created_at ?? "";
223
+ const lastReplyAt = replies.find((entry) => entry.seq === maxReplySeq)?.created_at ?? "";
224
+ const ticketState = detail.ticket_state;
225
+ return [
226
+ detail.run?.status ?? "",
227
+ detail.run?.created_at ?? "",
228
+ detail.dispatch_count ?? dispatches.length,
229
+ detail.reply_count ?? replies.length,
230
+ maxDispatchSeq,
231
+ maxReplySeq,
232
+ lastDispatchAt ?? "",
233
+ lastReplyAt ?? "",
234
+ ticketState?.dispatch_seq ?? "",
235
+ ticketState?.reply_seq ?? "",
236
+ ticketState?.status ?? "",
237
+ ticketState?.current_ticket ?? "",
238
+ ticketState?.total_turns ?? "",
239
+ ticketState?.ticket_turns ?? "",
240
+ ].join("|");
241
+ }
242
+ const threadListRefresh = createSmartRefresh({
243
+ getSignature: (payload) => {
244
+ if (payload.status !== "ok")
245
+ return payload.status;
246
+ return `ok::${threadListSignature(payload.conversations)}`;
247
+ },
248
+ render: (payload) => {
249
+ if (!threadsEl)
250
+ return;
251
+ const renderList = () => {
252
+ if (payload.status !== "ok") {
253
+ threadsEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized</div>";
254
+ return;
255
+ }
256
+ const conversations = payload.conversations || [];
257
+ if (!conversations.length) {
258
+ threadsEl.innerHTML = "<div class=\"muted\">No dispatches</div>";
259
+ return;
260
+ }
261
+ threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
262
+ threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
263
+ btn.addEventListener("click", () => {
264
+ const runId = btn.dataset.runId || "";
265
+ if (!runId)
266
+ return;
267
+ selectedRunId = runId;
268
+ syncSelectedThread();
269
+ updateUrlParams({ tab: "inbox", run_id: runId });
270
+ showThreadDetail();
271
+ void loadThread(runId, "manual");
272
+ });
273
+ });
274
+ };
275
+ preserveScroll(threadsEl, renderList, { restoreOnNextFrame: true });
276
+ },
277
+ });
278
+ const threadDetailRefresh = createSmartRefresh({
279
+ getSignature: (payload) => {
280
+ if (payload.status !== "ok")
281
+ return `${payload.status}::${payload.runId}`;
282
+ if (!payload.detail)
283
+ return `empty::${payload.runId}`;
284
+ return `ok::${payload.runId}::${threadDetailSignature(payload.detail)}`;
285
+ },
286
+ render: (payload, ctx) => {
287
+ if (!detailEl)
288
+ return;
289
+ if (payload.status !== "ok") {
290
+ detailEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized.</div>";
291
+ return;
292
+ }
293
+ const detail = payload.detail;
294
+ if (!detail) {
295
+ detailEl.innerHTML = "<div class=\"muted\">No thread selected.</div>";
296
+ return;
297
+ }
298
+ renderThreadDetail(detail, payload.runId, ctx);
299
+ },
300
+ });
301
+ async function fetchThreadsPayload() {
302
+ if (!isRepoHealthy()) {
303
+ return { status: "offline", conversations: [] };
304
+ }
305
+ const res = (await api("/api/messages/threads"));
306
+ return { status: "ok", conversations: res?.conversations || [] };
307
+ }
308
+ async function loadThreads(reason = "manual") {
309
+ if (!threadsEl)
310
+ return;
311
+ if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
312
+ reason = "manual";
313
+ }
314
+ const showFullLoading = reason === "initial";
315
+ if (showFullLoading) {
316
+ threadsEl.innerHTML = "Loading…";
317
+ }
318
+ else {
319
+ setThreadListRefreshing(true);
320
+ }
321
+ try {
322
+ await threadListRefresh.refresh(fetchThreadsPayload, { reason });
323
+ }
324
+ catch (_err) {
325
+ if (showFullLoading) {
326
+ threadsEl.innerHTML = "";
327
+ }
328
+ flash("Failed to load inbox", "error");
329
+ }
330
+ finally {
331
+ if (!showFullLoading) {
332
+ setThreadListRefreshing(false);
333
+ }
334
+ }
335
+ }
336
+ function formatBytes(size) {
337
+ if (typeof size !== "number" || Number.isNaN(size))
338
+ return "";
339
+ if (size >= 1000000)
340
+ return `${(size / 1000000).toFixed(1)} MB`;
341
+ if (size >= 1000)
342
+ return `${(size / 1000).toFixed(0)} KB`;
343
+ return `${size} B`;
344
+ }
345
+ export function renderMarkdown(body) {
346
+ if (!body)
347
+ return "";
348
+ let text = escapeHtml(body);
349
+ // Extract fenced code blocks to avoid mutating their contents later.
350
+ const codeBlocks = [];
351
+ text = text.replace(/```([\s\S]*?)```/g, (_m, code) => {
352
+ const placeholder = `@@CODEBLOCK_${codeBlocks.length}@@`;
353
+ codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
354
+ return placeholder;
355
+ });
356
+ // Extract inline code to avoid linking inside it
357
+ const inlineCode = [];
358
+ text = text.replace(/`([^`]+)`/g, (_m, code) => {
359
+ const placeholder = `@@INLINECODE_${inlineCode.length}@@`;
360
+ inlineCode.push(`<code>${code}</code>`);
361
+ return placeholder;
362
+ });
363
+ // Bold and italic (simple, non-nested)
364
+ text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
365
+ text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
366
+ // Extract markdown links [text](url) to avoid double-linking
367
+ const links = [];
368
+ text = text.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, (_m, label, url) => {
369
+ const placeholder = `@@LINK_${links.length}@@`;
370
+ // Note: label and url are already escaped because text is escaped.
371
+ links.push(`<a href="${url}" target="_blank" rel="noopener">${label}</a>`);
372
+ return placeholder;
373
+ });
374
+ // Auto-link raw URLs
375
+ text = text.replace(/(https?:\/\/[^\s]+)/g, (url) => {
376
+ let cleanUrl = url;
377
+ let suffix = "";
378
+ const trailing = /[.,;!?)]$/;
379
+ while (trailing.test(cleanUrl)) {
380
+ suffix = cleanUrl.slice(-1) + suffix;
381
+ cleanUrl = cleanUrl.slice(0, -1);
382
+ }
383
+ return `<a href="${cleanUrl}" target="_blank" rel="noopener">${cleanUrl}</a>${suffix}`;
384
+ });
385
+ // Restore markdown links
386
+ text = text.replace(/@@LINK_(\d+)@@/g, (_m, id) => {
387
+ return links[Number(id)] ?? "";
388
+ });
389
+ // Restore inline code
390
+ text = text.replace(/@@INLINECODE_(\d+)@@/g, (_m, id) => {
391
+ return inlineCode[Number(id)] ?? "";
392
+ });
393
+ // Lists (skip placeholders so code fences remain untouched)
394
+ const lines = text.split(/\n/);
395
+ const out = [];
396
+ let inList = false;
397
+ lines.forEach((line) => {
398
+ if (/^@@CODEBLOCK_\d+@@$/.test(line)) {
399
+ if (inList) {
400
+ out.push("</ul>");
401
+ inList = false;
402
+ }
403
+ out.push(line);
404
+ return;
405
+ }
406
+ if (/^[-*]\s+/.test(line)) {
407
+ if (!inList) {
408
+ out.push("", "<ul>");
409
+ inList = true;
410
+ }
411
+ out.push(`<li>${line.replace(/^[-*]\s+/, "")}</li>`);
412
+ }
413
+ else {
414
+ if (inList) {
415
+ out.push("</ul>", "");
416
+ inList = false;
417
+ }
418
+ out.push(line);
419
+ }
420
+ });
421
+ if (inList)
422
+ out.push("</ul>", "");
423
+ // Paragraphs and placeholder restoration
424
+ const joined = out.join("\n");
425
+ return joined
426
+ .split(/\n\n+/)
427
+ .map((block) => {
428
+ if (block.trim().startsWith("<ul>")) {
429
+ return block;
430
+ }
431
+ const match = block.match(/^@@CODEBLOCK_(\d+)@@$/);
432
+ if (match) {
433
+ const idx = Number(match[1]);
434
+ return codeBlocks[idx] ?? "";
435
+ }
436
+ return `<p>${block.replace(/\n/g, "<br>")}</p>`;
437
+ })
438
+ .join("");
439
+ }
440
+ function renderFiles(files) {
441
+ if (!files || !files.length)
442
+ return "";
443
+ const items = files
444
+ .map((f) => {
445
+ const size = formatBytes(f.size);
446
+ const href = resolvePath(f.url || "");
447
+ return `<li class="messages-file">
448
+ <span class="messages-file-icon">📎</span>
449
+ <a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(f.name)}</a>
450
+ ${size ? `<span class="messages-file-size muted small">${escapeHtml(size)}</span>` : ""}
451
+ </li>`;
452
+ })
453
+ .join("");
454
+ return `<ul class="messages-files">${items}</ul>`;
455
+ }
456
+ function renderDispatch(entry, isLatest, runStatus, isLastInTimeline = false) {
457
+ const dispatch = entry.dispatch;
458
+ const isHandoff = dispatch?.is_handoff || dispatch?.mode === "pause";
459
+ const isNotify = dispatch?.mode === "notify";
460
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
461
+ const title = dispatch?.title || (isHandoff ? "Handoff" : "Agent update");
462
+ let modeClass = "pill-info";
463
+ let modeLabel = "INFO";
464
+ if (isHandoff) {
465
+ // Only show "ACTION REQUIRED" if this is the latest dispatch AND the run is actually paused.
466
+ // Otherwise, show "HANDOFF" to indicate a historical pause point.
467
+ if (isLatest && runStatus === "paused") {
468
+ modeClass = "pill-action";
469
+ modeLabel = "ACTION REQUIRED";
470
+ }
471
+ else {
472
+ modeClass = "pill-idle";
473
+ modeLabel = "HANDOFF";
474
+ }
475
+ }
476
+ // Determine dispatch type for color coding
477
+ let dispatchTypeClass = "";
478
+ if (isHandoff) {
479
+ dispatchTypeClass = "dispatch-pause";
480
+ }
481
+ else if (isNotify) {
482
+ dispatchTypeClass = "dispatch-notify";
483
+ }
484
+ else if (isTurnSummary) {
485
+ dispatchTypeClass = "dispatch-turn";
486
+ }
487
+ // Collapse all but the last dispatch in the timeline
488
+ const isCollapsed = !isLastInTimeline;
489
+ const modePill = dispatch?.mode ? ` <span class="pill pill-small ${modeClass}">${escapeHtml(modeLabel)}</span>` : "";
490
+ const body = dispatch?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(dispatch.body)}</div>` : "";
491
+ const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
492
+ const collapseTitle = isCollapsed ? "Click to expand" : "Click to collapse";
493
+ return `
494
+ <div class="messages-entry${dispatchTypeClass ? " " + dispatchTypeClass : ""}${isCollapsed ? " collapsed" : ""}"
495
+ data-seq="${entry.seq}"
496
+ data-type="dispatch"
497
+ data-created="${escapeHtml(entry.created_at || "")}">
498
+ <div class="messages-collapse-bar"
499
+ role="button"
500
+ tabindex="0"
501
+ title="${collapseTitle}"
502
+ aria-label="${isCollapsed ? "Expand dispatch" : "Collapse dispatch"}"
503
+ aria-expanded="${String(!isCollapsed)}"></div>
504
+ <div class="messages-content-wrapper">
505
+ <div class="messages-entry-header">
506
+ <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
507
+ <span class="messages-entry-title">${escapeHtml(title)}</span>
508
+ ${modePill}
509
+ <span class="messages-entry-time">${escapeHtml(ts)}</span>
510
+ </div>
511
+ <div class="messages-entry-body">
512
+ ${body}
513
+ ${renderFiles(entry.files)}
514
+ </div>
515
+ </div>
516
+ </div>
517
+ `;
518
+ }
519
+ function renderReply(entry, parentSeq) {
520
+ const rep = entry.reply;
521
+ const title = rep?.title || "Your reply";
522
+ const body = rep?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(rep.body)}</div>` : "";
523
+ const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
524
+ const replyIndicator = parentSeq !== undefined
525
+ ? `<div class="messages-reply-indicator">In response to #${parentSeq.toString().padStart(4, "0")}</div>`
526
+ : "";
527
+ return `
528
+ <div class="messages-entry messages-entry-reply" data-seq="${entry.seq}" data-type="reply" data-created="${escapeHtml(entry.created_at || "")}">
529
+ <div class="messages-collapse-bar"
530
+ role="button"
531
+ tabindex="0"
532
+ title="Click to collapse"
533
+ aria-label="Collapse reply"
534
+ aria-expanded="true"></div>
535
+ <div class="messages-content-wrapper">
536
+ ${replyIndicator}
537
+ <div class="messages-entry-header">
538
+ <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
539
+ <span class="messages-entry-title">${escapeHtml(title)}</span>
540
+ <span class="pill pill-small pill-idle">you</span>
541
+ <span class="messages-entry-time">${escapeHtml(ts)}</span>
542
+ </div>
543
+ <div class="messages-entry-body">
544
+ ${body}
545
+ ${renderFiles(entry.files)}
546
+ </div>
547
+ </div>
548
+ </div>
549
+ `;
550
+ }
551
+ function buildThreadedTimeline(dispatches, replies, runStatus) {
552
+ // Combine all entries into a single timeline
553
+ const timeline = [];
554
+ // Find the latest dispatch sequence number to identify the most recent agent message
555
+ let maxDispatchSeq = -1;
556
+ dispatches.forEach((d) => {
557
+ if (d.seq > maxDispatchSeq)
558
+ maxDispatchSeq = d.seq;
559
+ timeline.push({
560
+ type: "dispatch",
561
+ seq: d.seq,
562
+ created_at: d.created_at || null,
563
+ dispatch: d,
564
+ });
565
+ });
566
+ replies.forEach((r) => {
567
+ timeline.push({
568
+ type: "reply",
569
+ seq: r.seq,
570
+ created_at: r.created_at || null,
571
+ reply: r,
572
+ });
573
+ });
574
+ // Sort chronologically by created_at, fallback to seq
575
+ timeline.sort((a, b) => {
576
+ if (a.created_at && b.created_at) {
577
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
578
+ }
579
+ return a.seq - b.seq;
580
+ });
581
+ // Count total dispatches in the sorted timeline
582
+ let dispatchCount = 0;
583
+ timeline.forEach((entry) => {
584
+ if (entry.type === "dispatch") {
585
+ dispatchCount++;
586
+ }
587
+ });
588
+ // Render timeline, associating replies with preceding dispatches
589
+ let lastDispatchSeq;
590
+ let currentDispatchIndex = 0;
591
+ const rendered = [];
592
+ timeline.forEach((entry) => {
593
+ if (entry.type === "dispatch" && entry.dispatch) {
594
+ lastDispatchSeq = entry.dispatch.seq;
595
+ const isLatest = entry.dispatch.seq === maxDispatchSeq;
596
+ const isLastInTimeline = currentDispatchIndex === dispatchCount - 1;
597
+ rendered.push(renderDispatch(entry.dispatch, isLatest, runStatus, isLastInTimeline));
598
+ currentDispatchIndex++;
599
+ }
600
+ else if (entry.type === "reply" && entry.reply) {
601
+ rendered.push(renderReply(entry.reply, lastDispatchSeq));
602
+ }
603
+ });
604
+ return rendered.join("");
605
+ }
606
+ async function loadThread(runId, reason = "manual") {
607
+ selectedRunId = runId;
608
+ syncSelectedThread();
609
+ if (!detailEl)
610
+ return;
611
+ if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
612
+ reason = "manual";
613
+ }
614
+ const showFullLoading = reason === "initial";
615
+ if (showFullLoading) {
616
+ detailEl.innerHTML = "Loading…";
617
+ }
618
+ else {
619
+ setThreadDetailRefreshing(true);
620
+ }
621
+ try {
622
+ await threadDetailRefresh.refresh(async () => {
623
+ if (!isRepoHealthy()) {
624
+ return { status: "offline", runId };
625
+ }
626
+ const detail = (await api(`/api/messages/threads/${encodeURIComponent(runId)}`));
627
+ return { status: "ok", runId, detail };
628
+ }, { reason });
629
+ }
630
+ catch (_err) {
631
+ if (showFullLoading) {
632
+ detailEl.innerHTML = "";
633
+ }
634
+ flash("Failed to load message thread", "error");
635
+ }
636
+ finally {
637
+ if (!showFullLoading) {
638
+ setThreadDetailRefreshing(false);
639
+ }
640
+ }
641
+ }
642
+ function isAtBottom(el) {
643
+ const threshold = 8;
644
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
645
+ }
646
+ function updateMobileDetailHeader(status, dispatchCount, replyCount) {
647
+ const statusEl = document.getElementById("messages-detail-status");
648
+ const countsEl = document.getElementById("messages-detail-counts");
649
+ if (statusEl) {
650
+ statusEl.className = `messages-detail-status pill pill-small ${getStatusPillClass(status)}`;
651
+ statusEl.textContent = status || "idle";
652
+ }
653
+ if (countsEl) {
654
+ countsEl.textContent = `${dispatchCount}D · ${replyCount}R`;
655
+ }
656
+ }
657
+ function attachCollapseHandlers() {
658
+ if (!detailEl)
659
+ return;
660
+ // Helper to toggle collapse state
661
+ const toggleEntry = (entry, bar) => {
662
+ const isNowCollapsed = entry.classList.toggle("collapsed");
663
+ bar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
664
+ bar.setAttribute("aria-expanded", String(!isNowCollapsed));
665
+ bar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
666
+ };
667
+ // Attach handlers to collapse bars
668
+ const collapseBars = detailEl.querySelectorAll(".messages-collapse-bar");
669
+ collapseBars.forEach((bar) => {
670
+ // Remove existing listeners by cloning
671
+ const newBar = bar.cloneNode(true);
672
+ bar.parentNode?.replaceChild(newBar, bar);
673
+ newBar.addEventListener("click", (e) => {
674
+ e.stopPropagation();
675
+ const entry = newBar.closest(".messages-entry");
676
+ if (entry) {
677
+ toggleEntry(entry, newBar);
678
+ }
679
+ });
680
+ // Keyboard support
681
+ newBar.addEventListener("keydown", (e) => {
682
+ if (e.key === "Enter" || e.key === " ") {
683
+ e.preventDefault();
684
+ const entry = newBar.closest(".messages-entry");
685
+ if (entry) {
686
+ toggleEntry(entry, newBar);
687
+ }
688
+ }
689
+ });
690
+ });
691
+ // Also make headers clickable for collapse
692
+ const headers = detailEl.querySelectorAll(".messages-entry-header");
693
+ headers.forEach((header) => {
694
+ // Remove existing listeners by cloning
695
+ const newHeader = header.cloneNode(true);
696
+ header.parentNode?.replaceChild(newHeader, header);
697
+ newHeader.addEventListener("click", (e) => {
698
+ // Don't toggle if clicking on a link
699
+ if (e.target.closest("a"))
700
+ return;
701
+ e.stopPropagation();
702
+ const entry = newHeader.closest(".messages-entry");
703
+ const bar = entry?.querySelector(".messages-collapse-bar");
704
+ if (entry && bar) {
705
+ toggleEntry(entry, bar);
706
+ }
707
+ });
708
+ });
709
+ }
710
+ function renderThreadDetail(detail, runId, ctx) {
711
+ if (!detailEl)
712
+ return;
713
+ const runStatus = (detail.run?.status || "").toString();
714
+ const isPaused = runStatus === "paused";
715
+ const dispatchHistory = detail.dispatch_history || [];
716
+ const replyHistory = detail.reply_history || [];
717
+ const dispatchCount = detail.dispatch_count ?? dispatchHistory.length;
718
+ const replyCount = detail.reply_count ?? replyHistory.length;
719
+ const ticketState = detail.ticket_state;
720
+ const turns = ticketState?.total_turns ?? null;
721
+ // Update mobile header metadata
722
+ updateMobileDetailHeader(runStatus, dispatchCount, replyCount);
723
+ // Truncate run ID for display
724
+ const shortRunId = runId.length > 12 ? runId.slice(0, 8) + "…" : runId;
725
+ // Build compact stats line
726
+ const statsParts = [];
727
+ statsParts.push(`${dispatchCount} dispatch${dispatchCount !== 1 ? "es" : ""}`);
728
+ statsParts.push(`${replyCount} repl${replyCount !== 1 ? "ies" : "y"}`);
729
+ if (turns != null)
730
+ statsParts.push(`${turns} turn${turns !== 1 ? "s" : ""}`);
731
+ const statsLine = statsParts.join(" · ");
732
+ // Status pill
733
+ const statusPillClass = isPaused ? "pill-action" : "pill-idle";
734
+ const statusLabel = isPaused ? "paused" : runStatus || "idle";
735
+ // Build threaded timeline
736
+ const threadedContent = buildThreadedTimeline(dispatchHistory, replyHistory, runStatus);
737
+ const renderDetail = () => {
738
+ detailEl.innerHTML = `
739
+ <div class="messages-thread-history">
740
+ ${threadedContent || '<div class="muted">No dispatches yet</div>'}
741
+ </div>
742
+ <div class="messages-thread-footer">
743
+ <code title="${escapeHtml(runId)}">${escapeHtml(shortRunId)}</code>
744
+ <span class="pill pill-small ${statusPillClass}">${escapeHtml(statusLabel)}</span>
745
+ <span class="messages-footer-stats">${escapeHtml(statsLine)}</span>
746
+ </div>
747
+ `;
748
+ };
749
+ const preserve = ctx.reason === "background" && detailEl.scrollHeight > 0 && !isAtBottom(detailEl);
750
+ if (preserve) {
751
+ preserveScroll(detailEl, () => {
752
+ renderDetail();
753
+ attachCollapseHandlers();
754
+ }, { restoreOnNextFrame: true });
755
+ }
756
+ else {
757
+ renderDetail();
758
+ attachCollapseHandlers();
759
+ }
760
+ // Only show reply box for paused runs - replies to other states won't be seen
761
+ const replyBoxEl = document.querySelector(".messages-reply-box");
762
+ if (replyBoxEl) {
763
+ replyBoxEl.classList.toggle("hidden", !isPaused);
764
+ }
765
+ if (!preserve) {
766
+ requestAnimationFrame(() => {
767
+ if (detailEl) {
768
+ detailEl.scrollTop = detailEl.scrollHeight;
769
+ }
770
+ });
771
+ }
772
+ }
773
+ async function sendReply() {
774
+ const runId = selectedRunId;
775
+ if (!runId) {
776
+ flash("Select a message thread first", "error");
777
+ return;
778
+ }
779
+ if (!isRepoHealthy()) {
780
+ flash("Repo offline; cannot send reply.", "error");
781
+ return;
782
+ }
783
+ const body = replyBodyEl?.value || "";
784
+ const fd = new FormData();
785
+ fd.append("body", body);
786
+ if (replyFilesEl?.files) {
787
+ Array.from(replyFilesEl.files).forEach((f) => fd.append("files", f));
788
+ }
789
+ try {
790
+ await api(`/api/messages/${encodeURIComponent(runId)}/reply`, {
791
+ method: "POST",
792
+ body: fd,
793
+ });
794
+ if (replyBodyEl)
795
+ replyBodyEl.value = "";
796
+ if (replyFilesEl)
797
+ replyFilesEl.value = "";
798
+ flash("Reply sent", "success");
799
+ // Always resume after sending
800
+ await api(`/api/flows/${encodeURIComponent(runId)}/resume`, { method: "POST" });
801
+ flash("Run resumed", "success");
802
+ void refreshBell();
803
+ void loadThread(runId);
804
+ }
805
+ catch (_err) {
806
+ flash("Failed to send reply", "error");
807
+ }
808
+ }
809
+ export function initMessages() {
810
+ if (messagesInitialized)
811
+ return;
812
+ if (!threadsEl || !detailEl)
813
+ return;
814
+ messagesInitialized = true;
815
+ backBtn?.addEventListener("click", showThreadList);
816
+ window.addEventListener("resize", () => {
817
+ if (!isMobileViewport()) {
818
+ layoutEl?.classList.remove("viewing-detail");
819
+ }
820
+ });
821
+ refreshEl?.addEventListener("click", () => {
822
+ void loadThreads("manual");
823
+ const runId = selectedRunId;
824
+ if (runId)
825
+ void loadThread(runId, "manual");
826
+ });
827
+ replySendEl?.addEventListener("click", () => {
828
+ void sendReply();
829
+ });
830
+ // Load threads immediately, and try to open run_id from URL if present.
831
+ void loadThreads("initial").then(() => {
832
+ const params = getUrlParams();
833
+ const runId = params.get("run_id");
834
+ if (runId) {
835
+ selectedRunId = runId;
836
+ showThreadDetail();
837
+ void loadThread(runId, "initial");
838
+ return;
839
+ }
840
+ // Fall back to active message if any.
841
+ if (activeRunId) {
842
+ selectedRunId = activeRunId;
843
+ updateUrlParams({ run_id: activeRunId });
844
+ showThreadDetail();
845
+ void loadThread(activeRunId, "initial");
846
+ }
847
+ });
848
+ subscribe("tab:change", (tabId) => {
849
+ if (tabId === "inbox") {
850
+ void refreshBell();
851
+ void loadThreads("manual");
852
+ const params = getUrlParams();
853
+ const runId = params.get("run_id");
854
+ if (runId) {
855
+ selectedRunId = runId;
856
+ showThreadDetail();
857
+ void loadThread(runId, "manual");
858
+ }
859
+ }
860
+ });
861
+ subscribe("state:update", () => {
862
+ void refreshBell();
863
+ });
864
+ subscribe("repo:health", (payload) => {
865
+ const status = payload?.status || "";
866
+ if (status === "ok" || status === "degraded" || status === "offline") {
867
+ void loadThreads("background");
868
+ if (selectedRunId) {
869
+ void loadThread(selectedRunId, "background");
870
+ }
871
+ }
872
+ });
873
+ }