codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -2,16 +2,70 @@
2
2
  import { api, escapeHtml, flash, getUrlParams, resolvePath, updateUrlParams, } from "./utils.js";
3
3
  import { subscribe } from "./bus.js";
4
4
  import { isRepoHealthy } from "./health.js";
5
+ import { preserveScroll } from "./preserve.js";
6
+ import { createSmartRefresh } from "./smartRefresh.js";
7
+ import { createFileBoxWidget } from "./fileboxUi.js";
5
8
  let bellInitialized = false;
6
9
  let messagesInitialized = false;
7
10
  let activeRunId = null;
8
11
  let selectedRunId = null;
12
+ const MESSAGE_REFRESH_REASONS = ["initial", "background", "manual"];
9
13
  const threadsEl = document.getElementById("messages-thread-list");
10
14
  const detailEl = document.getElementById("messages-thread-detail");
15
+ const layoutEl = document.querySelector(".messages-layout");
16
+ const backBtn = document.getElementById("messages-back-btn");
11
17
  const refreshEl = document.getElementById("messages-refresh");
12
18
  const replyBodyEl = document.getElementById("messages-reply-body");
13
19
  const replyFilesEl = document.getElementById("messages-reply-files");
14
20
  const replySendEl = document.getElementById("messages-reply-send");
21
+ const replyAttachBtn = document.getElementById("messages-reply-attach");
22
+ const replyAttachSummary = document.getElementById("messages-reply-attach-summary");
23
+ const fileBoxInboxEl = document.getElementById("messages-filebox-inbox");
24
+ const fileBoxOutboxEl = document.getElementById("messages-filebox-outbox");
25
+ const fileBoxUploadEl = document.getElementById("messages-filebox-upload");
26
+ const fileBoxUploadBtn = document.getElementById("messages-filebox-upload-btn");
27
+ const fileBoxRefreshBtn = document.getElementById("messages-filebox-refresh");
28
+ let threadListRefreshCount = 0;
29
+ let threadDetailRefreshCount = 0;
30
+ let fileBoxCtrl = null;
31
+ function isMobileViewport() {
32
+ return window.innerWidth <= 640;
33
+ }
34
+ function showThreadList() {
35
+ layoutEl?.classList.remove("viewing-detail");
36
+ }
37
+ function showThreadDetail() {
38
+ if (isMobileViewport()) {
39
+ layoutEl?.classList.add("viewing-detail");
40
+ }
41
+ }
42
+ function initFileBox() {
43
+ if (fileBoxCtrl || (!fileBoxInboxEl && !fileBoxOutboxEl))
44
+ return;
45
+ fileBoxCtrl = createFileBoxWidget({
46
+ scope: "repo",
47
+ inboxEl: fileBoxInboxEl,
48
+ outboxEl: fileBoxOutboxEl,
49
+ uploadInput: fileBoxUploadEl,
50
+ uploadBtn: fileBoxUploadBtn,
51
+ refreshBtn: fileBoxRefreshBtn,
52
+ uploadBox: "inbox",
53
+ emptyMessage: "No files",
54
+ });
55
+ void fileBoxCtrl.refresh();
56
+ }
57
+ function setThreadListRefreshing(active) {
58
+ if (!threadsEl)
59
+ return;
60
+ threadListRefreshCount = Math.max(0, threadListRefreshCount + (active ? 1 : -1));
61
+ threadsEl.classList.toggle("refreshing", threadListRefreshCount > 0);
62
+ }
63
+ function setThreadDetailRefreshing(active) {
64
+ if (!detailEl)
65
+ return;
66
+ threadDetailRefreshCount = Math.max(0, threadDetailRefreshCount + (active ? 1 : -1));
67
+ detailEl.classList.toggle("refreshing", threadDetailRefreshCount > 0);
68
+ }
15
69
  function formatTimestamp(ts) {
16
70
  if (!ts)
17
71
  return "–";
@@ -76,12 +130,51 @@ export function initMessageBell() {
76
130
  }
77
131
  });
78
132
  }
133
+ function formatRelativeTime(ts) {
134
+ if (!ts)
135
+ return "";
136
+ const date = new Date(ts);
137
+ if (Number.isNaN(date.getTime()))
138
+ return "";
139
+ const now = new Date();
140
+ const diffMs = now.getTime() - date.getTime();
141
+ const diffSecs = Math.floor(diffMs / 1000);
142
+ if (diffSecs < 60)
143
+ return "just now";
144
+ const diffMins = Math.floor(diffSecs / 60);
145
+ if (diffMins < 60)
146
+ return `${diffMins}m ago`;
147
+ const diffHours = Math.floor(diffMins / 60);
148
+ if (diffHours < 24)
149
+ return `${diffHours}h ago`;
150
+ const diffDays = Math.floor(diffHours / 24);
151
+ if (diffDays < 7)
152
+ return `${diffDays}d ago`;
153
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
154
+ }
155
+ function getStatusPillClass(status) {
156
+ switch (status) {
157
+ case "paused":
158
+ return "pill-action";
159
+ case "running":
160
+ case "pending":
161
+ return "pill-success";
162
+ case "completed":
163
+ return "pill-idle";
164
+ case "failed":
165
+ case "stopped":
166
+ return "pill-error";
167
+ default:
168
+ return "pill-idle";
169
+ }
170
+ }
79
171
  function renderThreadItem(thread) {
80
172
  const latestDispatch = thread.latest?.dispatch;
81
173
  const isHandoff = latestDispatch?.is_handoff || latestDispatch?.mode === "pause";
82
174
  const title = latestDispatch?.title || (isHandoff ? "Handoff" : "Dispatch");
83
175
  const subtitle = latestDispatch?.body ? latestDispatch.body.slice(0, 120) : "";
84
176
  const isPaused = thread.status === "paused";
177
+ const isActive = selectedRunId && thread.run_id === selectedRunId;
85
178
  // Only show action indicator if there's an unreplied handoff (pause)
86
179
  // Compare dispatch_seq vs reply_seq to check if user has responded
87
180
  const ticketState = thread.ticket_state;
@@ -91,47 +184,178 @@ function renderThreadItem(thread) {
91
184
  const indicator = hasUnrepliedHandoff ? `<span class="messages-thread-indicator" title="Action required"></span>` : "";
92
185
  const dispatches = thread.dispatch_count ?? 0;
93
186
  const replies = thread.reply_count ?? 0;
94
- const metaLine = `${dispatches} dispatch${dispatches !== 1 ? "es" : ""} · ${replies} repl${replies !== 1 ? "ies" : "y"}`;
187
+ // Format timestamp for last dispatch
188
+ const lastTs = thread.latest?.created_at;
189
+ const timeAgo = formatRelativeTime(lastTs);
190
+ // Status badge
191
+ const status = thread.status || "idle";
192
+ const statusClass = getStatusPillClass(status);
193
+ const statusLabel = status === "paused" && hasUnrepliedHandoff ? "action" : status;
194
+ // Build meta line with timestamp
195
+ const countPart = `${dispatches} dispatch${dispatches !== 1 ? "es" : ""} · ${replies} repl${replies !== 1 ? "ies" : "y"}`;
95
196
  return `
96
- <button class="messages-thread" data-run-id="${escapeHtml(thread.run_id)}">
97
- <div class="messages-thread-title">${indicator}${escapeHtml(title)}</div>
197
+ <button class="messages-thread${isActive ? " active" : ""}" data-run-id="${escapeHtml(thread.run_id)}">
198
+ <div class="messages-thread-header">
199
+ <div class="messages-thread-title">${indicator}${escapeHtml(title)}</div>
200
+ <span class="pill pill-small ${statusClass}">${escapeHtml(statusLabel)}</span>
201
+ </div>
98
202
  <div class="messages-thread-subtitle muted">${escapeHtml(subtitle)}</div>
99
- <div class="messages-thread-meta-line">${escapeHtml(metaLine)}</div>
203
+ <div class="messages-thread-meta-line">
204
+ <span class="messages-thread-counts">${escapeHtml(countPart)}</span>
205
+ ${timeAgo ? `<span class="messages-thread-time">${escapeHtml(timeAgo)}</span>` : ""}
206
+ </div>
100
207
  </button>
101
208
  `;
102
209
  }
103
- async function loadThreads() {
210
+ function syncSelectedThread() {
104
211
  if (!threadsEl)
105
212
  return;
106
- threadsEl.innerHTML = "Loading…";
213
+ const buttons = threadsEl.querySelectorAll(".messages-thread");
214
+ buttons.forEach((btn) => {
215
+ const runId = btn.dataset.runId || "";
216
+ btn.classList.toggle("active", Boolean(runId) && runId === selectedRunId);
217
+ });
218
+ }
219
+ function threadListSignature(conversations) {
220
+ return conversations
221
+ .map((thread) => {
222
+ const latest = thread.latest;
223
+ const dispatch = latest?.dispatch;
224
+ const ticketState = thread.ticket_state;
225
+ return [
226
+ thread.run_id,
227
+ thread.status ?? "",
228
+ latest?.seq ?? "",
229
+ latest?.created_at ?? "",
230
+ dispatch?.mode ?? "",
231
+ dispatch?.is_handoff ? "1" : "0",
232
+ thread.dispatch_count ?? "",
233
+ thread.reply_count ?? "",
234
+ ticketState?.dispatch_seq ?? "",
235
+ ticketState?.reply_seq ?? "",
236
+ ticketState?.status ?? "",
237
+ ].join("|");
238
+ })
239
+ .join("::");
240
+ }
241
+ function threadDetailSignature(detail) {
242
+ const dispatches = detail.dispatch_history || [];
243
+ const replies = detail.reply_history || [];
244
+ const maxDispatchSeq = dispatches.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
245
+ const maxReplySeq = replies.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
246
+ const lastDispatchAt = dispatches.find((entry) => entry.seq === maxDispatchSeq)?.created_at ?? "";
247
+ const lastReplyAt = replies.find((entry) => entry.seq === maxReplySeq)?.created_at ?? "";
248
+ const ticketState = detail.ticket_state;
249
+ return [
250
+ detail.run?.status ?? "",
251
+ detail.run?.created_at ?? "",
252
+ detail.dispatch_count ?? dispatches.length,
253
+ detail.reply_count ?? replies.length,
254
+ maxDispatchSeq,
255
+ maxReplySeq,
256
+ lastDispatchAt ?? "",
257
+ lastReplyAt ?? "",
258
+ ticketState?.dispatch_seq ?? "",
259
+ ticketState?.reply_seq ?? "",
260
+ ticketState?.status ?? "",
261
+ ticketState?.current_ticket ?? "",
262
+ ticketState?.total_turns ?? "",
263
+ ticketState?.ticket_turns ?? "",
264
+ ].join("|");
265
+ }
266
+ const threadListRefresh = createSmartRefresh({
267
+ getSignature: (payload) => {
268
+ if (payload.status !== "ok")
269
+ return payload.status;
270
+ return `ok::${threadListSignature(payload.conversations)}`;
271
+ },
272
+ render: (payload) => {
273
+ if (!threadsEl)
274
+ return;
275
+ const renderList = () => {
276
+ if (payload.status !== "ok") {
277
+ threadsEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized</div>";
278
+ return;
279
+ }
280
+ const conversations = payload.conversations || [];
281
+ if (!conversations.length) {
282
+ threadsEl.innerHTML = "<div class=\"muted\">No dispatches</div>";
283
+ return;
284
+ }
285
+ threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
286
+ threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
287
+ btn.addEventListener("click", () => {
288
+ const runId = btn.dataset.runId || "";
289
+ if (!runId)
290
+ return;
291
+ selectedRunId = runId;
292
+ syncSelectedThread();
293
+ updateUrlParams({ tab: "inbox", run_id: runId });
294
+ showThreadDetail();
295
+ void loadThread(runId, "manual");
296
+ });
297
+ });
298
+ };
299
+ preserveScroll(threadsEl, renderList, { restoreOnNextFrame: true });
300
+ },
301
+ });
302
+ const threadDetailRefresh = createSmartRefresh({
303
+ getSignature: (payload) => {
304
+ if (payload.status !== "ok")
305
+ return `${payload.status}::${payload.runId}`;
306
+ if (!payload.detail)
307
+ return `empty::${payload.runId}`;
308
+ return `ok::${payload.runId}::${threadDetailSignature(payload.detail)}`;
309
+ },
310
+ render: (payload, ctx) => {
311
+ if (!detailEl)
312
+ return;
313
+ if (payload.status !== "ok") {
314
+ detailEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized.</div>";
315
+ return;
316
+ }
317
+ const detail = payload.detail;
318
+ if (!detail) {
319
+ detailEl.innerHTML = "<div class=\"muted\">No thread selected.</div>";
320
+ return;
321
+ }
322
+ renderThreadDetail(detail, payload.runId, ctx);
323
+ },
324
+ });
325
+ async function fetchThreadsPayload() {
107
326
  if (!isRepoHealthy()) {
108
- threadsEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized</div>";
327
+ return { status: "offline", conversations: [] };
328
+ }
329
+ const res = (await api("/api/messages/threads"));
330
+ return { status: "ok", conversations: res?.conversations || [] };
331
+ }
332
+ async function loadThreads(reason = "manual") {
333
+ if (!threadsEl)
109
334
  return;
335
+ if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
336
+ reason = "manual";
337
+ }
338
+ const showFullLoading = reason === "initial";
339
+ if (showFullLoading) {
340
+ threadsEl.innerHTML = "Loading…";
341
+ }
342
+ else {
343
+ setThreadListRefreshing(true);
110
344
  }
111
- let res;
112
345
  try {
113
- res = (await api("/api/messages/threads"));
346
+ await threadListRefresh.refresh(fetchThreadsPayload, { reason });
114
347
  }
115
- catch (err) {
116
- threadsEl.innerHTML = "";
348
+ catch (_err) {
349
+ if (showFullLoading) {
350
+ threadsEl.innerHTML = "";
351
+ }
117
352
  flash("Failed to load inbox", "error");
118
- return;
119
353
  }
120
- const conversations = res?.conversations || [];
121
- if (!conversations.length) {
122
- threadsEl.innerHTML = "<div class=\"muted\">No dispatches</div>";
123
- return;
354
+ finally {
355
+ if (!showFullLoading) {
356
+ setThreadListRefreshing(false);
357
+ }
124
358
  }
125
- threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
126
- threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
127
- btn.addEventListener("click", () => {
128
- const runId = btn.dataset.runId || "";
129
- if (!runId)
130
- return;
131
- updateUrlParams({ tab: "inbox", run_id: runId });
132
- void loadThread(runId);
133
- });
134
- });
135
359
  }
136
360
  function formatBytes(size) {
137
361
  if (typeof size !== "number" || Number.isNaN(size))
@@ -142,6 +366,27 @@ function formatBytes(size) {
142
366
  return `${(size / 1000).toFixed(0)} KB`;
143
367
  return `${size} B`;
144
368
  }
369
+ function isSafeHref(url) {
370
+ const trimmed = (url || "").trim();
371
+ if (!trimmed)
372
+ return false;
373
+ const lower = trimmed.toLowerCase();
374
+ if (lower.startsWith("javascript:"))
375
+ return false;
376
+ if (lower.startsWith("data:"))
377
+ return false;
378
+ if (lower.startsWith("vbscript:"))
379
+ return false;
380
+ if (lower.startsWith("file:"))
381
+ return false;
382
+ return (lower.startsWith("http://") ||
383
+ lower.startsWith("https://") ||
384
+ trimmed.startsWith("/") ||
385
+ trimmed.startsWith("./") ||
386
+ trimmed.startsWith("../") ||
387
+ trimmed.startsWith("#") ||
388
+ lower.startsWith("mailto:"));
389
+ }
145
390
  export function renderMarkdown(body) {
146
391
  if (!body)
147
392
  return "";
@@ -153,14 +398,46 @@ export function renderMarkdown(body) {
153
398
  codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
154
399
  return placeholder;
155
400
  });
156
- // Inline code
157
- text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
401
+ // Extract inline code to avoid linking inside it
402
+ const inlineCode = [];
403
+ text = text.replace(/`([^`]+)`/g, (_m, code) => {
404
+ const placeholder = `@@INLINECODE_${inlineCode.length}@@`;
405
+ inlineCode.push(`<code>${code}</code>`);
406
+ return placeholder;
407
+ });
158
408
  // Bold and italic (simple, non-nested)
159
409
  text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
160
410
  text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
161
- // Links [text](url) only for http/https
162
- text = text.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, (_m, label, url) => {
163
- return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
411
+ // Extract markdown links [text](url) to avoid double-linking
412
+ const links = [];
413
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, rawUrl) => {
414
+ const url = (rawUrl || "").trim();
415
+ if (!isSafeHref(url)) {
416
+ return match;
417
+ }
418
+ const placeholder = `@@LINK_${links.length}@@`;
419
+ // Note: label and url are already escaped because text is escaped.
420
+ links.push(`<a href="${url}" target="_blank" rel="noopener">${label}</a>`);
421
+ return placeholder;
422
+ });
423
+ // Auto-link raw URLs
424
+ text = text.replace(/(https?:\/\/[^\s]+)/g, (url) => {
425
+ let cleanUrl = url;
426
+ let suffix = "";
427
+ const trailing = /[.,;!?)]$/;
428
+ while (trailing.test(cleanUrl)) {
429
+ suffix = cleanUrl.slice(-1) + suffix;
430
+ cleanUrl = cleanUrl.slice(0, -1);
431
+ }
432
+ return `<a href="${cleanUrl}" target="_blank" rel="noopener">${cleanUrl}</a>${suffix}`;
433
+ });
434
+ // Restore markdown links
435
+ text = text.replace(/@@LINK_(\d+)@@/g, (_m, id) => {
436
+ return links[Number(id)] ?? "";
437
+ });
438
+ // Restore inline code
439
+ text = text.replace(/@@INLINECODE_(\d+)@@/g, (_m, id) => {
440
+ return inlineCode[Number(id)] ?? "";
164
441
  });
165
442
  // Lists (skip placeholders so code fences remain untouched)
166
443
  const lines = text.split(/\n/);
@@ -177,32 +454,36 @@ export function renderMarkdown(body) {
177
454
  }
178
455
  if (/^[-*]\s+/.test(line)) {
179
456
  if (!inList) {
180
- out.push("<ul>");
457
+ out.push("", "<ul>");
181
458
  inList = true;
182
459
  }
183
460
  out.push(`<li>${line.replace(/^[-*]\s+/, "")}</li>`);
184
461
  }
185
462
  else {
186
463
  if (inList) {
187
- out.push("</ul>");
464
+ out.push("</ul>", "");
188
465
  inList = false;
189
466
  }
190
467
  out.push(line);
191
468
  }
192
469
  });
193
470
  if (inList)
194
- out.push("</ul>");
471
+ out.push("</ul>", "");
195
472
  // Paragraphs and placeholder restoration
196
473
  const joined = out.join("\n");
197
474
  return joined
198
475
  .split(/\n\n+/)
199
476
  .map((block) => {
477
+ if (block.trim().startsWith("<ul>")) {
478
+ return block;
479
+ }
200
480
  const match = block.match(/^@@CODEBLOCK_(\d+)@@$/);
201
481
  if (match) {
202
482
  const idx = Number(match[1]);
203
483
  return codeBlocks[idx] ?? "";
204
484
  }
205
- return `<p>${block.replace(/\n/g, "<br>")}</p>`;
485
+ const content = block.replace(/\n/g, "<br>").replace(/@@CODEBLOCK_(\d+)@@/g, (_m, id) => codeBlocks[Number(id)] ?? "");
486
+ return `<p>${content}</p>`;
206
487
  })
207
488
  .join("");
208
489
  }
@@ -213,18 +494,23 @@ function renderFiles(files) {
213
494
  .map((f) => {
214
495
  const size = formatBytes(f.size);
215
496
  const href = resolvePath(f.url || "");
497
+ const isHttp = /^https?:\/\//.test(href);
498
+ const targetAttrs = isHttp ? ' target="_blank" rel="noopener"' : "";
499
+ const downloadAttr = isHttp ? "" : " download";
216
500
  return `<li class="messages-file">
217
501
  <span class="messages-file-icon">📎</span>
218
- <a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(f.name)}</a>
502
+ <a href="${escapeHtml(href)}"${downloadAttr}${targetAttrs}>${escapeHtml(f.name)}</a>
219
503
  ${size ? `<span class="messages-file-size muted small">${escapeHtml(size)}</span>` : ""}
220
504
  </li>`;
221
505
  })
222
506
  .join("");
223
507
  return `<ul class="messages-files">${items}</ul>`;
224
508
  }
225
- function renderDispatch(entry, isLatest, runStatus) {
509
+ function renderDispatch(entry, isLatest, runStatus, isLastInTimeline = false) {
226
510
  const dispatch = entry.dispatch;
227
511
  const isHandoff = dispatch?.is_handoff || dispatch?.mode === "pause";
512
+ const isNotify = dispatch?.mode === "notify";
513
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
228
514
  const title = dispatch?.title || (isHandoff ? "Handoff" : "Agent update");
229
515
  let modeClass = "pill-info";
230
516
  let modeLabel = "INFO";
@@ -240,19 +526,46 @@ function renderDispatch(entry, isLatest, runStatus) {
240
526
  modeLabel = "HANDOFF";
241
527
  }
242
528
  }
529
+ // Determine dispatch type for color coding
530
+ let dispatchTypeClass = "";
531
+ if (isHandoff) {
532
+ dispatchTypeClass = "dispatch-pause";
533
+ }
534
+ else if (isNotify) {
535
+ dispatchTypeClass = "dispatch-notify";
536
+ }
537
+ else if (isTurnSummary) {
538
+ dispatchTypeClass = "dispatch-turn";
539
+ }
540
+ // Collapse all but the last dispatch in the timeline
541
+ const isCollapsed = !isLastInTimeline;
243
542
  const modePill = dispatch?.mode ? ` <span class="pill pill-small ${modeClass}">${escapeHtml(modeLabel)}</span>` : "";
244
543
  const body = dispatch?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(dispatch.body)}</div>` : "";
245
544
  const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
545
+ const collapseTitle = isCollapsed ? "Click to expand" : "Click to collapse";
246
546
  return `
247
- <div class="messages-entry" data-seq="${entry.seq}" data-type="dispatch" data-created="${escapeHtml(entry.created_at || "")}">
248
- <div class="messages-entry-header">
249
- <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
250
- <span class="messages-entry-title">${escapeHtml(title)}</span>
251
- ${modePill}
252
- <span class="messages-entry-time">${escapeHtml(ts)}</span>
547
+ <div class="messages-entry${dispatchTypeClass ? " " + dispatchTypeClass : ""}${isCollapsed ? " collapsed" : ""}"
548
+ data-seq="${entry.seq}"
549
+ data-type="dispatch"
550
+ data-created="${escapeHtml(entry.created_at || "")}">
551
+ <div class="messages-collapse-bar"
552
+ role="button"
553
+ tabindex="0"
554
+ title="${collapseTitle}"
555
+ aria-label="${isCollapsed ? "Expand dispatch" : "Collapse dispatch"}"
556
+ aria-expanded="${String(!isCollapsed)}"></div>
557
+ <div class="messages-content-wrapper">
558
+ <div class="messages-entry-header">
559
+ <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
560
+ <span class="messages-entry-title">${escapeHtml(title)}</span>
561
+ ${modePill}
562
+ <span class="messages-entry-time">${escapeHtml(ts)}</span>
563
+ </div>
564
+ <div class="messages-entry-body">
565
+ ${body}
566
+ ${renderFiles(entry.files)}
567
+ </div>
253
568
  </div>
254
- ${body}
255
- ${renderFiles(entry.files)}
256
569
  </div>
257
570
  `;
258
571
  }
@@ -266,15 +579,25 @@ function renderReply(entry, parentSeq) {
266
579
  : "";
267
580
  return `
268
581
  <div class="messages-entry messages-entry-reply" data-seq="${entry.seq}" data-type="reply" data-created="${escapeHtml(entry.created_at || "")}">
269
- ${replyIndicator}
270
- <div class="messages-entry-header">
271
- <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
272
- <span class="messages-entry-title">${escapeHtml(title)}</span>
273
- <span class="pill pill-small pill-idle">you</span>
274
- <span class="messages-entry-time">${escapeHtml(ts)}</span>
582
+ <div class="messages-collapse-bar"
583
+ role="button"
584
+ tabindex="0"
585
+ title="Click to collapse"
586
+ aria-label="Collapse reply"
587
+ aria-expanded="true"></div>
588
+ <div class="messages-content-wrapper">
589
+ ${replyIndicator}
590
+ <div class="messages-entry-header">
591
+ <span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
592
+ <span class="messages-entry-title">${escapeHtml(title)}</span>
593
+ <span class="pill pill-small pill-idle">you</span>
594
+ <span class="messages-entry-time">${escapeHtml(ts)}</span>
595
+ </div>
596
+ <div class="messages-entry-body">
597
+ ${body}
598
+ ${renderFiles(entry.files)}
599
+ </div>
275
600
  </div>
276
- ${body}
277
- ${renderFiles(entry.files)}
278
601
  </div>
279
602
  `;
280
603
  }
@@ -308,14 +631,24 @@ function buildThreadedTimeline(dispatches, replies, runStatus) {
308
631
  }
309
632
  return a.seq - b.seq;
310
633
  });
634
+ // Count total dispatches in the sorted timeline
635
+ let dispatchCount = 0;
636
+ timeline.forEach((entry) => {
637
+ if (entry.type === "dispatch") {
638
+ dispatchCount++;
639
+ }
640
+ });
311
641
  // Render timeline, associating replies with preceding dispatches
312
642
  let lastDispatchSeq;
643
+ let currentDispatchIndex = 0;
313
644
  const rendered = [];
314
645
  timeline.forEach((entry) => {
315
646
  if (entry.type === "dispatch" && entry.dispatch) {
316
647
  lastDispatchSeq = entry.dispatch.seq;
317
648
  const isLatest = entry.dispatch.seq === maxDispatchSeq;
318
- rendered.push(renderDispatch(entry.dispatch, isLatest, runStatus));
649
+ const isLastInTimeline = currentDispatchIndex === dispatchCount - 1;
650
+ rendered.push(renderDispatch(entry.dispatch, isLatest, runStatus, isLastInTimeline));
651
+ currentDispatchIndex++;
319
652
  }
320
653
  else if (entry.type === "reply" && entry.reply) {
321
654
  rendered.push(renderReply(entry.reply, lastDispatchSeq));
@@ -323,24 +656,113 @@ function buildThreadedTimeline(dispatches, replies, runStatus) {
323
656
  });
324
657
  return rendered.join("");
325
658
  }
326
- async function loadThread(runId) {
659
+ async function loadThread(runId, reason = "manual") {
327
660
  selectedRunId = runId;
661
+ syncSelectedThread();
328
662
  if (!detailEl)
329
663
  return;
330
- detailEl.innerHTML = "Loading…";
331
- if (!isRepoHealthy()) {
332
- detailEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized.</div>";
333
- return;
664
+ if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
665
+ reason = "manual";
666
+ }
667
+ const showFullLoading = reason === "initial";
668
+ if (showFullLoading) {
669
+ detailEl.innerHTML = "Loading…";
670
+ }
671
+ else {
672
+ setThreadDetailRefreshing(true);
334
673
  }
335
- let detail;
336
674
  try {
337
- detail = (await api(`/api/messages/threads/${encodeURIComponent(runId)}`));
675
+ await threadDetailRefresh.refresh(async () => {
676
+ if (!isRepoHealthy()) {
677
+ return { status: "offline", runId };
678
+ }
679
+ const detail = (await api(`/api/messages/threads/${encodeURIComponent(runId)}`));
680
+ return { status: "ok", runId, detail };
681
+ }, { reason });
338
682
  }
339
683
  catch (_err) {
340
- detailEl.innerHTML = "";
684
+ if (showFullLoading) {
685
+ detailEl.innerHTML = "";
686
+ }
341
687
  flash("Failed to load message thread", "error");
342
- return;
343
688
  }
689
+ finally {
690
+ if (!showFullLoading) {
691
+ setThreadDetailRefreshing(false);
692
+ }
693
+ }
694
+ }
695
+ function isAtBottom(el) {
696
+ const threshold = 8;
697
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
698
+ }
699
+ function updateMobileDetailHeader(status, dispatchCount, replyCount) {
700
+ const statusEl = document.getElementById("messages-detail-status");
701
+ const countsEl = document.getElementById("messages-detail-counts");
702
+ if (statusEl) {
703
+ statusEl.className = `messages-detail-status pill pill-small ${getStatusPillClass(status)}`;
704
+ statusEl.textContent = status || "idle";
705
+ }
706
+ if (countsEl) {
707
+ countsEl.textContent = `${dispatchCount}D · ${replyCount}R`;
708
+ }
709
+ }
710
+ function attachCollapseHandlers() {
711
+ if (!detailEl)
712
+ return;
713
+ // Helper to toggle collapse state
714
+ const toggleEntry = (entry, bar) => {
715
+ const isNowCollapsed = entry.classList.toggle("collapsed");
716
+ bar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
717
+ bar.setAttribute("aria-expanded", String(!isNowCollapsed));
718
+ bar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
719
+ };
720
+ // Attach handlers to collapse bars
721
+ const collapseBars = detailEl.querySelectorAll(".messages-collapse-bar");
722
+ collapseBars.forEach((bar) => {
723
+ // Remove existing listeners by cloning
724
+ const newBar = bar.cloneNode(true);
725
+ bar.parentNode?.replaceChild(newBar, bar);
726
+ newBar.addEventListener("click", (e) => {
727
+ e.stopPropagation();
728
+ const entry = newBar.closest(".messages-entry");
729
+ if (entry) {
730
+ toggleEntry(entry, newBar);
731
+ }
732
+ });
733
+ // Keyboard support
734
+ newBar.addEventListener("keydown", (e) => {
735
+ if (e.key === "Enter" || e.key === " ") {
736
+ e.preventDefault();
737
+ const entry = newBar.closest(".messages-entry");
738
+ if (entry) {
739
+ toggleEntry(entry, newBar);
740
+ }
741
+ }
742
+ });
743
+ });
744
+ // Also make headers clickable for collapse
745
+ const headers = detailEl.querySelectorAll(".messages-entry-header");
746
+ headers.forEach((header) => {
747
+ // Remove existing listeners by cloning
748
+ const newHeader = header.cloneNode(true);
749
+ header.parentNode?.replaceChild(newHeader, header);
750
+ newHeader.addEventListener("click", (e) => {
751
+ // Don't toggle if clicking on a link
752
+ if (e.target.closest("a"))
753
+ return;
754
+ e.stopPropagation();
755
+ const entry = newHeader.closest(".messages-entry");
756
+ const bar = entry?.querySelector(".messages-collapse-bar");
757
+ if (entry && bar) {
758
+ toggleEntry(entry, bar);
759
+ }
760
+ });
761
+ });
762
+ }
763
+ function renderThreadDetail(detail, runId, ctx) {
764
+ if (!detailEl)
765
+ return;
344
766
  const runStatus = (detail.run?.status || "").toString();
345
767
  const isPaused = runStatus === "paused";
346
768
  const dispatchHistory = detail.dispatch_history || [];
@@ -349,6 +771,8 @@ async function loadThread(runId) {
349
771
  const replyCount = detail.reply_count ?? replyHistory.length;
350
772
  const ticketState = detail.ticket_state;
351
773
  const turns = ticketState?.total_turns ?? null;
774
+ // Update mobile header metadata
775
+ updateMobileDetailHeader(runStatus, dispatchCount, replyCount);
352
776
  // Truncate run ID for display
353
777
  const shortRunId = runId.length > 12 ? runId.slice(0, 8) + "…" : runId;
354
778
  // Build compact stats line
@@ -363,27 +787,54 @@ async function loadThread(runId) {
363
787
  const statusLabel = isPaused ? "paused" : runStatus || "idle";
364
788
  // Build threaded timeline
365
789
  const threadedContent = buildThreadedTimeline(dispatchHistory, replyHistory, runStatus);
366
- detailEl.innerHTML = `
367
- <div class="messages-thread-history">
368
- ${threadedContent || '<div class="muted">No dispatches yet</div>'}
369
- </div>
370
- <div class="messages-thread-footer">
371
- <code title="${escapeHtml(runId)}">${escapeHtml(shortRunId)}</code>
372
- <span class="pill pill-small ${statusPillClass}">${escapeHtml(statusLabel)}</span>
373
- <span class="messages-footer-stats">${escapeHtml(statsLine)}</span>
374
- </div>
375
- `;
790
+ const renderDetail = () => {
791
+ detailEl.innerHTML = `
792
+ <div class="messages-thread-history">
793
+ ${threadedContent || '<div class="muted">No dispatches yet</div>'}
794
+ </div>
795
+ <div class="messages-thread-footer">
796
+ <code title="${escapeHtml(runId)}">${escapeHtml(shortRunId)}</code>
797
+ <span class="pill pill-small ${statusPillClass}">${escapeHtml(statusLabel)}</span>
798
+ <span class="messages-footer-stats">${escapeHtml(statsLine)}</span>
799
+ </div>
800
+ `;
801
+ };
802
+ const preserve = ctx.reason === "background" && detailEl.scrollHeight > 0 && !isAtBottom(detailEl);
803
+ if (preserve) {
804
+ preserveScroll(detailEl, () => {
805
+ renderDetail();
806
+ attachCollapseHandlers();
807
+ }, { restoreOnNextFrame: true });
808
+ }
809
+ else {
810
+ renderDetail();
811
+ attachCollapseHandlers();
812
+ }
376
813
  // Only show reply box for paused runs - replies to other states won't be seen
377
- const replyBoxEl = document.querySelector(".messages-reply-box");
814
+ const replyBoxEl = document.querySelector(".messages-compose");
378
815
  if (replyBoxEl) {
379
816
  replyBoxEl.classList.toggle("hidden", !isPaused);
380
817
  }
381
- // Always scroll to bottom of the thread detail (the scrollable container)
382
- requestAnimationFrame(() => {
383
- if (detailEl) {
384
- detailEl.scrollTop = detailEl.scrollHeight;
385
- }
386
- });
818
+ if (!preserve) {
819
+ requestAnimationFrame(() => {
820
+ if (detailEl) {
821
+ detailEl.scrollTop = detailEl.scrollHeight;
822
+ }
823
+ });
824
+ }
825
+ }
826
+ function refreshAttachSummary() {
827
+ if (!replyAttachSummary || !replyFilesEl)
828
+ return;
829
+ const files = Array.from(replyFilesEl.files || []);
830
+ if (!files.length) {
831
+ replyAttachSummary.textContent = "";
832
+ replyAttachSummary.classList.add("hidden");
833
+ return;
834
+ }
835
+ const label = files.length > 2 ? `${files.length} files` : files.map((f) => f.name).join(", ");
836
+ replyAttachSummary.textContent = label;
837
+ replyAttachSummary.classList.remove("hidden");
387
838
  }
388
839
  async function sendReply() {
389
840
  const runId = selectedRunId;
@@ -410,6 +861,7 @@ async function sendReply() {
410
861
  replyBodyEl.value = "";
411
862
  if (replyFilesEl)
412
863
  replyFilesEl.value = "";
864
+ refreshAttachSummary();
413
865
  flash("Reply sent", "success");
414
866
  // Always resume after sending
415
867
  await api(`/api/flows/${encodeURIComponent(runId)}/resume`, { method: "POST" });
@@ -427,44 +879,73 @@ export function initMessages() {
427
879
  if (!threadsEl || !detailEl)
428
880
  return;
429
881
  messagesInitialized = true;
882
+ initFileBox();
883
+ backBtn?.addEventListener("click", showThreadList);
884
+ window.addEventListener("resize", () => {
885
+ if (!isMobileViewport()) {
886
+ layoutEl?.classList.remove("viewing-detail");
887
+ }
888
+ });
430
889
  refreshEl?.addEventListener("click", () => {
431
- void loadThreads();
890
+ void loadThreads("manual");
432
891
  const runId = selectedRunId;
433
892
  if (runId)
434
- void loadThread(runId);
893
+ void loadThread(runId, "manual");
435
894
  });
436
895
  replySendEl?.addEventListener("click", () => {
437
896
  void sendReply();
438
897
  });
898
+ replyAttachBtn?.addEventListener("click", () => {
899
+ replyFilesEl?.click();
900
+ });
901
+ replyFilesEl?.addEventListener("change", () => {
902
+ refreshAttachSummary();
903
+ });
904
+ refreshAttachSummary();
439
905
  // Load threads immediately, and try to open run_id from URL if present.
440
- void loadThreads().then(() => {
906
+ void loadThreads("initial").then(() => {
441
907
  const params = getUrlParams();
442
908
  const runId = params.get("run_id");
443
909
  if (runId) {
444
910
  selectedRunId = runId;
445
- void loadThread(runId);
911
+ showThreadDetail();
912
+ void loadThread(runId, "initial");
446
913
  return;
447
914
  }
448
915
  // Fall back to active message if any.
449
916
  if (activeRunId) {
450
917
  selectedRunId = activeRunId;
451
918
  updateUrlParams({ run_id: activeRunId });
452
- void loadThread(activeRunId);
919
+ showThreadDetail();
920
+ void loadThread(activeRunId, "initial");
453
921
  }
454
922
  });
455
923
  subscribe("tab:change", (tabId) => {
456
924
  if (tabId === "inbox") {
457
925
  void refreshBell();
458
- void loadThreads();
926
+ void loadThreads("manual");
459
927
  const params = getUrlParams();
460
928
  const runId = params.get("run_id");
461
929
  if (runId) {
462
930
  selectedRunId = runId;
463
- void loadThread(runId);
931
+ showThreadDetail();
932
+ void loadThread(runId, "manual");
464
933
  }
465
934
  }
466
935
  });
467
936
  subscribe("state:update", () => {
468
937
  void refreshBell();
469
938
  });
939
+ subscribe("repo:health", (payload) => {
940
+ const status = payload?.status || "";
941
+ if (status === "ok" || status === "degraded" || status === "offline") {
942
+ void loadThreads("background");
943
+ if (selectedRunId) {
944
+ void loadThread(selectedRunId, "background");
945
+ }
946
+ if (status === "ok" || status === "degraded") {
947
+ void fileBoxCtrl?.refresh();
948
+ }
949
+ }
950
+ });
470
951
  }