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,1988 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ import { api, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal, inputModal, setButtonLoading, } from "./utils.js";
3
+ // Note: activateTab removed - header now used for collapse, not inbox navigation
4
+ import { registerAutoRefresh } from "./autoRefresh.js";
5
+ import { CONSTANTS } from "./constants.js";
6
+ import { subscribe } from "./bus.js";
7
+ import { isRepoHealthy } from "./health.js";
8
+ import { closeTicketEditor, initTicketEditor, openTicketEditor } from "./ticketEditor.js";
9
+ import { parseAppServerEvent } from "./agentEvents.js";
10
+ import { summarizeEvents, renderCompactSummary, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
11
+ import { refreshBell, renderMarkdown } from "./messages.js";
12
+ import { preserveScroll } from "./preserve.js";
13
+ import { createSmartRefresh } from "./smartRefresh.js";
14
+ function formatDispatchTime(ts) {
15
+ if (!ts)
16
+ return "";
17
+ const date = new Date(ts);
18
+ if (Number.isNaN(date.getTime()))
19
+ return "";
20
+ const now = new Date();
21
+ const diffMs = now.getTime() - date.getTime();
22
+ const diffSecs = Math.floor(diffMs / 1000);
23
+ if (diffSecs < 60)
24
+ return "now";
25
+ const diffMins = Math.floor(diffSecs / 60);
26
+ if (diffMins < 60)
27
+ return `${diffMins}m`;
28
+ const diffHours = Math.floor(diffMins / 60);
29
+ if (diffHours < 24)
30
+ return `${diffHours}h`;
31
+ const diffDays = Math.floor(diffHours / 24);
32
+ if (diffDays < 7)
33
+ return `${diffDays}d`;
34
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
35
+ }
36
+ /**
37
+ * Format a number for compact display (e.g., 1200 -> "1.2k").
38
+ */
39
+ function formatNumber(n) {
40
+ if (n >= 1000000) {
41
+ return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
42
+ }
43
+ if (n >= 1000) {
44
+ return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
45
+ }
46
+ return n.toString();
47
+ }
48
+ let currentRunId = null;
49
+ let ticketsExist = false;
50
+ let currentActiveTicket = null;
51
+ let currentFlowStatus = null;
52
+ let selectedTicketPath = null;
53
+ let elapsedTimerId = null;
54
+ let flowStartedAt = null;
55
+ let eventSource = null;
56
+ let eventSourceRunId = null;
57
+ let lastActivityTime = null;
58
+ let lastActivityTimerId = null;
59
+ let lastKnownEventSeq = null;
60
+ let lastKnownEventAt = null;
61
+ let liveOutputDetailExpanded = false; // Start with summary view, one click for full
62
+ let liveOutputBuffer = [];
63
+ const MAX_OUTPUT_LINES = 200;
64
+ const LIVE_EVENT_MAX = 50;
65
+ let liveOutputEvents = [];
66
+ let liveOutputEventIndex = {};
67
+ let currentReasonFull = null; // Full reason text for modal display
68
+ let dispatchHistoryRunId = null;
69
+ let eventSourceRetryAttempt = 0;
70
+ let eventSourceRetryTimerId = null;
71
+ const lastSeenSeqByRun = {};
72
+ let ticketListCache = null;
73
+ let ticketFlowLoaded = false;
74
+ function isFlowActiveStatus(status) {
75
+ // Mirror backend FlowRunStatus.is_active(): pending | running | stopping
76
+ return status === "pending" || status === "running" || status === "stopping";
77
+ }
78
+ // Dispatch panel collapse state (persisted to localStorage)
79
+ const DISPATCH_PANEL_COLLAPSED_KEY = "car-dispatch-panel-collapsed";
80
+ let dispatchPanelCollapsed = false;
81
+ const LAST_SEEN_SEQ_KEY_PREFIX = "car-ticket-flow-last-seq:";
82
+ const EVENT_STREAM_RETRY_DELAYS_MS = [500, 1000, 2000, 5000, 10000];
83
+ const STALE_THRESHOLD_MS = 30000;
84
+ // Throttling state
85
+ let liveOutputRenderPending = false;
86
+ let liveOutputTextPending = false;
87
+ const ticketListRefresh = createSmartRefresh({
88
+ getSignature: (payload) => {
89
+ const list = (payload.tickets || []);
90
+ const pieces = list.map((ticket) => {
91
+ const fm = (ticket.frontmatter || {});
92
+ const title = fm?.title ? String(fm.title) : "";
93
+ const done = fm?.done ? "1" : "0";
94
+ const agent = fm?.agent ? String(fm.agent) : "";
95
+ const mtime = ticket.mtime ?? "";
96
+ const errors = Array.isArray(ticket.errors) ? ticket.errors.join(",") : "";
97
+ return [ticket.path ?? "", ticket.index ?? "", title, done, agent, mtime, errors].join("|");
98
+ });
99
+ return [
100
+ payload.ticket_dir ?? "",
101
+ payload.activeTicket ?? "",
102
+ payload.flowStatus ?? "",
103
+ pieces.join(";"),
104
+ ].join("::");
105
+ },
106
+ render: (payload) => {
107
+ const { tickets } = els();
108
+ preserveScroll(tickets, () => {
109
+ renderTickets({
110
+ ticket_dir: payload.ticket_dir,
111
+ tickets: payload.tickets,
112
+ });
113
+ }, { restoreOnNextFrame: true });
114
+ },
115
+ onSkip: () => {
116
+ updateScrollFade();
117
+ },
118
+ });
119
+ const dispatchHistoryRefresh = createSmartRefresh({
120
+ getSignature: (payload) => {
121
+ const entries = payload.history || [];
122
+ const latestSeq = entries[0]?.seq ?? "";
123
+ return [payload.runId ?? "", latestSeq, entries.length].join("::");
124
+ },
125
+ render: (payload) => {
126
+ const { history } = els();
127
+ preserveScroll(history, () => {
128
+ renderDispatchHistory(payload.runId, { history: payload.history });
129
+ }, { restoreOnNextFrame: true });
130
+ },
131
+ onSkip: () => {
132
+ updateScrollFade();
133
+ },
134
+ });
135
+ function scheduleLiveOutputRender() {
136
+ if (liveOutputRenderPending)
137
+ return;
138
+ liveOutputRenderPending = true;
139
+ requestAnimationFrame(() => {
140
+ renderLiveOutputView();
141
+ liveOutputRenderPending = false;
142
+ });
143
+ }
144
+ function scheduleLiveOutputTextUpdate() {
145
+ if (liveOutputTextPending)
146
+ return;
147
+ liveOutputTextPending = true;
148
+ requestAnimationFrame(() => {
149
+ const outputEl = document.getElementById("ticket-live-output-text");
150
+ if (outputEl) {
151
+ const newText = liveOutputBuffer.join("\n");
152
+ if (outputEl.textContent !== newText) {
153
+ outputEl.textContent = newText;
154
+ }
155
+ // Auto-scroll to bottom when detail view is showing
156
+ const detailEl = document.getElementById("ticket-live-output-detail");
157
+ if (detailEl && liveOutputDetailExpanded) {
158
+ detailEl.scrollTop = detailEl.scrollHeight;
159
+ }
160
+ }
161
+ liveOutputTextPending = false;
162
+ });
163
+ }
164
+ /**
165
+ * Initialize dispatch panel collapse state from localStorage
166
+ */
167
+ function initDispatchPanelToggle() {
168
+ const { dispatchPanel, dispatchPanelToggle } = els();
169
+ if (!dispatchPanel || !dispatchPanelToggle)
170
+ return;
171
+ // Restore collapsed state from localStorage
172
+ const stored = localStorage.getItem(DISPATCH_PANEL_COLLAPSED_KEY);
173
+ dispatchPanelCollapsed = stored === "true";
174
+ if (dispatchPanelCollapsed) {
175
+ dispatchPanel.classList.add("collapsed");
176
+ }
177
+ // Handle toggle click
178
+ dispatchPanelToggle.addEventListener("click", () => {
179
+ dispatchPanelCollapsed = !dispatchPanelCollapsed;
180
+ dispatchPanel.classList.toggle("collapsed", dispatchPanelCollapsed);
181
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, String(dispatchPanelCollapsed));
182
+ });
183
+ }
184
+ /**
185
+ * Render mini dispatch items for collapsed panel view.
186
+ * Shows compact dispatch indicators that can be clicked to expand.
187
+ */
188
+ function renderDispatchMiniList(entries) {
189
+ const { dispatchMiniList, dispatchPanel } = els();
190
+ if (!dispatchMiniList)
191
+ return;
192
+ dispatchMiniList.innerHTML = "";
193
+ // Only show first 8 items in mini view
194
+ const maxMiniItems = 8;
195
+ entries.slice(0, maxMiniItems).forEach((entry) => {
196
+ const dispatch = entry.dispatch;
197
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
198
+ const isNotify = dispatch?.mode === "notify";
199
+ const mini = document.createElement("div");
200
+ mini.className = `dispatch-mini-item${isNotify ? " notify" : ""}`;
201
+ mini.textContent = `#${entry.seq || "?"}`;
202
+ mini.title = isTurnSummary
203
+ ? "Agent turn output"
204
+ : dispatch?.title || `Dispatch #${entry.seq}`;
205
+ // Click to expand panel and scroll to this item
206
+ mini.addEventListener("click", () => {
207
+ if (dispatchPanel && dispatchPanelCollapsed) {
208
+ dispatchPanelCollapsed = false;
209
+ dispatchPanel.classList.remove("collapsed");
210
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
211
+ }
212
+ });
213
+ dispatchMiniList.appendChild(mini);
214
+ });
215
+ // Show overflow indicator if more items
216
+ if (entries.length > maxMiniItems) {
217
+ const more = document.createElement("div");
218
+ more.className = "dispatch-mini-item";
219
+ more.textContent = `+${entries.length - maxMiniItems}`;
220
+ more.title = `${entries.length - maxMiniItems} more dispatches`;
221
+ more.addEventListener("click", () => {
222
+ if (dispatchPanel && dispatchPanelCollapsed) {
223
+ dispatchPanelCollapsed = false;
224
+ dispatchPanel.classList.remove("collapsed");
225
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
226
+ }
227
+ });
228
+ dispatchMiniList.appendChild(more);
229
+ }
230
+ }
231
+ function formatElapsed(startTime) {
232
+ const now = new Date();
233
+ const diffMs = now.getTime() - startTime.getTime();
234
+ const diffSecs = Math.floor(diffMs / 1000);
235
+ if (diffSecs < 60) {
236
+ return `${diffSecs}s`;
237
+ }
238
+ const mins = Math.floor(diffSecs / 60);
239
+ const secs = diffSecs % 60;
240
+ if (mins < 60) {
241
+ return `${mins}m ${secs}s`;
242
+ }
243
+ const hours = Math.floor(mins / 60);
244
+ const remainingMins = mins % 60;
245
+ return `${hours}h ${remainingMins}m`;
246
+ }
247
+ function startElapsedTimer() {
248
+ stopElapsedTimer();
249
+ if (!flowStartedAt)
250
+ return;
251
+ const update = () => {
252
+ const { elapsed } = els();
253
+ if (elapsed && flowStartedAt) {
254
+ elapsed.textContent = formatElapsed(flowStartedAt);
255
+ }
256
+ };
257
+ update(); // Update immediately
258
+ elapsedTimerId = setInterval(update, 1000);
259
+ }
260
+ function stopElapsedTimer() {
261
+ if (elapsedTimerId) {
262
+ clearInterval(elapsedTimerId);
263
+ elapsedTimerId = null;
264
+ }
265
+ }
266
+ // ---- SSE Event Stream Functions ----
267
+ function formatTimeAgo(timestamp) {
268
+ const now = new Date();
269
+ const diffMs = now.getTime() - timestamp.getTime();
270
+ const diffSecs = Math.floor(diffMs / 1000);
271
+ if (diffSecs < 5)
272
+ return "just now";
273
+ if (diffSecs < 60)
274
+ return `${diffSecs}s ago`;
275
+ const mins = Math.floor(diffSecs / 60);
276
+ if (mins < 60)
277
+ return `${mins}m ago`;
278
+ const hours = Math.floor(mins / 60);
279
+ return `${hours}h ago`;
280
+ }
281
+ function updateLastActivityDisplay() {
282
+ const el = document.getElementById("ticket-flow-last-activity");
283
+ if (el && lastActivityTime) {
284
+ el.textContent = formatTimeAgo(lastActivityTime);
285
+ }
286
+ }
287
+ function startLastActivityTimer() {
288
+ stopLastActivityTimer();
289
+ updateLastActivityDisplay();
290
+ lastActivityTimerId = setInterval(updateLastActivityDisplay, 1000);
291
+ }
292
+ function stopLastActivityTimer() {
293
+ if (lastActivityTimerId) {
294
+ clearInterval(lastActivityTimerId);
295
+ lastActivityTimerId = null;
296
+ }
297
+ }
298
+ function updateLastActivityFromTimestamp(timestamp) {
299
+ if (timestamp) {
300
+ const parsed = new Date(timestamp);
301
+ if (!Number.isNaN(parsed.getTime())) {
302
+ lastActivityTime = parsed;
303
+ lastKnownEventAt = parsed;
304
+ startLastActivityTimer();
305
+ return;
306
+ }
307
+ }
308
+ lastActivityTime = null;
309
+ lastKnownEventAt = null;
310
+ stopLastActivityTimer();
311
+ const { lastActivity } = els();
312
+ if (lastActivity)
313
+ lastActivity.textContent = "–";
314
+ }
315
+ function getLastSeenSeq(runId) {
316
+ if (lastSeenSeqByRun[runId] !== undefined) {
317
+ return lastSeenSeqByRun[runId];
318
+ }
319
+ const stored = localStorage.getItem(`${LAST_SEEN_SEQ_KEY_PREFIX}${runId}`);
320
+ if (!stored)
321
+ return null;
322
+ const parsed = Number.parseInt(stored, 10);
323
+ if (Number.isNaN(parsed))
324
+ return null;
325
+ lastSeenSeqByRun[runId] = parsed;
326
+ return parsed;
327
+ }
328
+ function setLastSeenSeq(runId, seq) {
329
+ if (!Number.isFinite(seq))
330
+ return;
331
+ const current = lastSeenSeqByRun[runId];
332
+ if (current !== undefined && seq <= current)
333
+ return;
334
+ lastSeenSeqByRun[runId] = seq;
335
+ localStorage.setItem(`${LAST_SEEN_SEQ_KEY_PREFIX}${runId}`, String(seq));
336
+ }
337
+ function parseEventSeq(event, lastEventId) {
338
+ if (typeof event.seq === "number" && Number.isFinite(event.seq)) {
339
+ return event.seq;
340
+ }
341
+ if (lastEventId) {
342
+ const parsed = Number.parseInt(lastEventId, 10);
343
+ if (!Number.isNaN(parsed))
344
+ return parsed;
345
+ }
346
+ return null;
347
+ }
348
+ function clearEventStreamRetry() {
349
+ if (eventSourceRetryTimerId) {
350
+ clearTimeout(eventSourceRetryTimerId);
351
+ eventSourceRetryTimerId = null;
352
+ }
353
+ }
354
+ function scheduleEventStreamReconnect(runId) {
355
+ if (eventSourceRetryTimerId)
356
+ return;
357
+ const index = Math.min(eventSourceRetryAttempt, EVENT_STREAM_RETRY_DELAYS_MS.length - 1);
358
+ const delay = EVENT_STREAM_RETRY_DELAYS_MS[index];
359
+ eventSourceRetryAttempt += 1;
360
+ eventSourceRetryTimerId = setTimeout(() => {
361
+ eventSourceRetryTimerId = null;
362
+ if (currentRunId !== runId)
363
+ return;
364
+ if (currentFlowStatus !== "running" && currentFlowStatus !== "pending")
365
+ return;
366
+ connectEventStream(runId);
367
+ }, delay);
368
+ }
369
+ function appendToLiveOutput(text) {
370
+ if (!text)
371
+ return;
372
+ const segments = text.split("\n");
373
+ // Merge first segment into the last buffered line to avoid artificial newlines between deltas
374
+ if (liveOutputBuffer.length === 0) {
375
+ liveOutputBuffer.push(segments[0]);
376
+ }
377
+ else {
378
+ liveOutputBuffer[liveOutputBuffer.length - 1] += segments[0];
379
+ }
380
+ // Remaining segments represent real new lines
381
+ for (let i = 1; i < segments.length; i++) {
382
+ liveOutputBuffer.push(segments[i]);
383
+ }
384
+ // Trim buffer if it exceeds max lines
385
+ while (liveOutputBuffer.length > MAX_OUTPUT_LINES) {
386
+ liveOutputBuffer.shift();
387
+ }
388
+ scheduleLiveOutputTextUpdate();
389
+ }
390
+ function addLiveOutputEvent(parsed) {
391
+ const { event, mergeStrategy } = parsed;
392
+ const itemId = event.itemId;
393
+ if (mergeStrategy && itemId && liveOutputEventIndex[itemId] !== undefined) {
394
+ const existingIndex = liveOutputEventIndex[itemId];
395
+ const existing = liveOutputEvents[existingIndex];
396
+ if (mergeStrategy === "append") {
397
+ existing.summary = `${existing.summary || ""}${event.summary}`;
398
+ }
399
+ else if (mergeStrategy === "newline") {
400
+ existing.summary = `${existing.summary || ""}\n\n`;
401
+ }
402
+ existing.time = event.time;
403
+ return;
404
+ }
405
+ liveOutputEvents.push(event);
406
+ if (liveOutputEvents.length > LIVE_EVENT_MAX) {
407
+ liveOutputEvents = liveOutputEvents.slice(-LIVE_EVENT_MAX);
408
+ liveOutputEventIndex = {};
409
+ liveOutputEvents.forEach((evt, idx) => {
410
+ if (evt.itemId)
411
+ liveOutputEventIndex[evt.itemId] = idx;
412
+ });
413
+ }
414
+ else if (itemId) {
415
+ liveOutputEventIndex[itemId] = liveOutputEvents.length - 1;
416
+ }
417
+ }
418
+ function renderLiveOutputEvents() {
419
+ const container = document.getElementById("ticket-live-output-events");
420
+ const list = document.getElementById("ticket-live-output-events-list");
421
+ const count = document.getElementById("ticket-live-output-events-count");
422
+ if (!container || !list || !count)
423
+ return;
424
+ const hasEvents = liveOutputEvents.length > 0;
425
+ if (count.textContent !== String(liveOutputEvents.length)) {
426
+ count.textContent = String(liveOutputEvents.length);
427
+ }
428
+ const shouldHide = !hasEvents || !liveOutputDetailExpanded;
429
+ if (container.classList.contains("hidden") !== shouldHide) {
430
+ container.classList.toggle("hidden", shouldHide);
431
+ }
432
+ if (shouldHide) {
433
+ if (list.innerHTML !== "")
434
+ list.innerHTML = "";
435
+ return;
436
+ }
437
+ // Track which IDs are currently in the list to remove stale ones
438
+ const currentIds = new Set();
439
+ liveOutputEvents.forEach((entry) => {
440
+ const id = entry.id;
441
+ currentIds.add(id);
442
+ // Safer lookup than querySelector with arbitrary ID
443
+ let wrapper = null;
444
+ for (let i = 0; i < list.children.length; i++) {
445
+ const child = list.children[i];
446
+ if (child.dataset.eventId === id) {
447
+ wrapper = child;
448
+ break;
449
+ }
450
+ }
451
+ if (!wrapper) {
452
+ wrapper = document.createElement("div");
453
+ wrapper.className = `ticket-chat-event ${entry.kind || ""}`.trim();
454
+ wrapper.dataset.eventId = id;
455
+ const title = document.createElement("div");
456
+ title.className = "ticket-chat-event-title";
457
+ wrapper.appendChild(title);
458
+ const summary = document.createElement("div");
459
+ summary.className = "ticket-chat-event-summary";
460
+ wrapper.appendChild(summary);
461
+ const detail = document.createElement("div");
462
+ detail.className = "ticket-chat-event-detail";
463
+ wrapper.appendChild(detail);
464
+ const meta = document.createElement("div");
465
+ meta.className = "ticket-chat-event-meta";
466
+ wrapper.appendChild(meta);
467
+ list.appendChild(wrapper);
468
+ }
469
+ // Efficiently update content only if changed
470
+ const titleEl = wrapper.querySelector(".ticket-chat-event-title");
471
+ const newTitle = entry.title || entry.method || "Update";
472
+ if (titleEl && titleEl.textContent !== newTitle) {
473
+ titleEl.textContent = newTitle;
474
+ }
475
+ const summaryEl = wrapper.querySelector(".ticket-chat-event-summary");
476
+ const newSummary = entry.summary || "";
477
+ if (summaryEl && summaryEl.textContent !== newSummary) {
478
+ summaryEl.textContent = newSummary;
479
+ }
480
+ const detailEl = wrapper.querySelector(".ticket-chat-event-detail");
481
+ const newDetail = entry.detail || "";
482
+ if (detailEl && detailEl.textContent !== newDetail) {
483
+ detailEl.textContent = newDetail;
484
+ }
485
+ const metaEl = wrapper.querySelector(".ticket-chat-event-meta");
486
+ if (metaEl) {
487
+ const newMeta = entry.time
488
+ ? new Date(entry.time).toLocaleTimeString([], {
489
+ hour: "2-digit",
490
+ minute: "2-digit",
491
+ })
492
+ : "";
493
+ if (metaEl.textContent !== newMeta) {
494
+ metaEl.textContent = newMeta;
495
+ }
496
+ }
497
+ });
498
+ // Remove stale events
499
+ Array.from(list.children).forEach((child) => {
500
+ const el = child;
501
+ if (el.dataset.eventId && !currentIds.has(el.dataset.eventId)) {
502
+ el.remove();
503
+ }
504
+ });
505
+ // Only scroll if near bottom or if height changed significantly?
506
+ // For now, just scroll as it's the expected behavior for live logs
507
+ list.scrollTop = list.scrollHeight;
508
+ }
509
+ function renderLiveOutputCompact() {
510
+ const compactEl = document.getElementById("ticket-live-output-compact");
511
+ if (!compactEl)
512
+ return;
513
+ const summary = summarizeEvents(liveOutputEvents, {
514
+ maxActions: 1, // Show only 1 action + thinking to fit in 3-line compact view
515
+ maxTextLength: COMPACT_MAX_TEXT_LENGTH,
516
+ startTime: flowStartedAt?.getTime(),
517
+ });
518
+ const text = liveOutputEvents.length ? renderCompactSummary(summary) : "";
519
+ const newText = text || "Waiting for agent output...";
520
+ if (compactEl.textContent !== newText) {
521
+ compactEl.textContent = newText;
522
+ }
523
+ }
524
+ function updateLiveOutputViewToggle() {
525
+ const viewToggle = document.getElementById("ticket-live-output-view-toggle");
526
+ if (!viewToggle)
527
+ return;
528
+ if (liveOutputDetailExpanded) {
529
+ if (!viewToggle.classList.contains("active"))
530
+ viewToggle.classList.add("active");
531
+ if (viewToggle.textContent !== "≡")
532
+ viewToggle.textContent = "≡";
533
+ if (viewToggle.title !== "Show summary")
534
+ viewToggle.title = "Show summary";
535
+ }
536
+ else {
537
+ if (viewToggle.classList.contains("active"))
538
+ viewToggle.classList.remove("active");
539
+ if (viewToggle.textContent !== "⋯")
540
+ viewToggle.textContent = "⋯";
541
+ if (viewToggle.title !== "Show full output")
542
+ viewToggle.title = "Show full output";
543
+ }
544
+ }
545
+ function renderLiveOutputView() {
546
+ const compactEl = document.getElementById("ticket-live-output-compact");
547
+ const detailEl = document.getElementById("ticket-live-output-detail");
548
+ const eventsEl = document.getElementById("ticket-live-output-events");
549
+ if (compactEl) {
550
+ compactEl.classList.toggle("hidden", liveOutputDetailExpanded);
551
+ }
552
+ if (detailEl) {
553
+ detailEl.classList.toggle("hidden", !liveOutputDetailExpanded);
554
+ }
555
+ if (eventsEl) {
556
+ eventsEl.classList.toggle("hidden", !liveOutputDetailExpanded);
557
+ }
558
+ renderLiveOutputCompact();
559
+ renderLiveOutputEvents();
560
+ updateLiveOutputViewToggle();
561
+ }
562
+ function clearLiveOutput() {
563
+ liveOutputBuffer = [];
564
+ const outputEl = document.getElementById("ticket-live-output-text");
565
+ if (outputEl)
566
+ outputEl.textContent = "";
567
+ liveOutputEvents = [];
568
+ liveOutputEventIndex = {};
569
+ scheduleLiveOutputRender();
570
+ }
571
+ function setLiveOutputStatus(status) {
572
+ const statusEl = document.getElementById("ticket-live-output-status");
573
+ if (!statusEl)
574
+ return;
575
+ statusEl.className = "ticket-live-output-status";
576
+ switch (status) {
577
+ case "disconnected":
578
+ statusEl.textContent = "Disconnected";
579
+ break;
580
+ case "connected":
581
+ statusEl.textContent = "Connected";
582
+ statusEl.classList.add("connected");
583
+ break;
584
+ case "streaming":
585
+ statusEl.textContent = "Streaming";
586
+ statusEl.classList.add("streaming");
587
+ break;
588
+ }
589
+ }
590
+ function handleFlowEvent(event) {
591
+ // Update last activity time
592
+ lastActivityTime = new Date(event.timestamp);
593
+ lastKnownEventAt = lastActivityTime;
594
+ updateLastActivityDisplay();
595
+ // Handle agent stream delta events
596
+ if (event.event_type === "agent_stream_delta") {
597
+ setLiveOutputStatus("streaming");
598
+ const delta = event.data?.delta || "";
599
+ if (delta) {
600
+ appendToLiveOutput(delta);
601
+ }
602
+ }
603
+ // Handle rich app-server events (tools, commands, files, thinking, etc.)
604
+ if (event.event_type === "app_server_event") {
605
+ const parsed = parseAppServerEvent(event.data);
606
+ if (parsed) {
607
+ addLiveOutputEvent(parsed);
608
+ scheduleLiveOutputRender();
609
+ }
610
+ }
611
+ // Handle step progress events carrying ticket selection so UI can highlight immediately
612
+ if (event.event_type === "step_progress") {
613
+ const nextTicket = event.data?.current_ticket;
614
+ if (nextTicket) {
615
+ currentActiveTicket = nextTicket;
616
+ // Don't force flow status here; it comes from the runs endpoint.
617
+ const { current } = els();
618
+ if (current)
619
+ current.textContent = currentActiveTicket;
620
+ if (ticketListCache) {
621
+ renderTickets(ticketListCache);
622
+ }
623
+ }
624
+ }
625
+ // Handle flow lifecycle events
626
+ if (event.event_type === "flow_completed" ||
627
+ event.event_type === "flow_failed" ||
628
+ event.event_type === "flow_stopped") {
629
+ setLiveOutputStatus("connected");
630
+ // Refresh the flow state
631
+ void loadTicketFlow();
632
+ }
633
+ // Handle step events
634
+ if (event.event_type === "step_started") {
635
+ const stepName = event.data?.step_name || "";
636
+ if (stepName) {
637
+ appendToLiveOutput(`\n--- Step: ${stepName} ---\n`);
638
+ }
639
+ }
640
+ }
641
+ function connectEventStream(runId, afterSeq) {
642
+ disconnectEventStream();
643
+ clearEventStreamRetry();
644
+ eventSourceRunId = runId;
645
+ const token = getAuthToken();
646
+ const url = new URL(resolvePath(`/api/flows/${runId}/events`), window.location.origin);
647
+ if (token) {
648
+ url.searchParams.set("token", token);
649
+ }
650
+ if (typeof afterSeq === "number") {
651
+ url.searchParams.set("after", String(afterSeq));
652
+ }
653
+ else {
654
+ const lastSeenSeq = getLastSeenSeq(runId);
655
+ if (typeof lastSeenSeq === "number") {
656
+ url.searchParams.set("after", String(lastSeenSeq));
657
+ }
658
+ }
659
+ eventSource = new EventSource(url.toString());
660
+ eventSource.onopen = () => {
661
+ setLiveOutputStatus("connected");
662
+ eventSourceRetryAttempt = 0;
663
+ clearEventStreamRetry();
664
+ };
665
+ eventSource.onmessage = (event) => {
666
+ try {
667
+ const data = JSON.parse(event.data);
668
+ const seq = parseEventSeq(data, event.lastEventId);
669
+ if (currentRunId && typeof seq === "number") {
670
+ setLastSeenSeq(currentRunId, seq);
671
+ lastKnownEventSeq = seq;
672
+ }
673
+ handleFlowEvent(data);
674
+ }
675
+ catch (err) {
676
+ // Ignore parse errors
677
+ }
678
+ };
679
+ eventSource.onerror = () => {
680
+ setLiveOutputStatus("disconnected");
681
+ if (eventSource) {
682
+ eventSource.close();
683
+ eventSource = null;
684
+ }
685
+ scheduleEventStreamReconnect(runId);
686
+ };
687
+ }
688
+ function disconnectEventStream() {
689
+ if (eventSource) {
690
+ eventSource.close();
691
+ eventSource = null;
692
+ }
693
+ clearEventStreamRetry();
694
+ eventSourceRunId = null;
695
+ setLiveOutputStatus("disconnected");
696
+ }
697
+ function initLiveOutputPanel() {
698
+ const viewToggleBtn = document.getElementById("ticket-live-output-view-toggle");
699
+ // Toggle between summary and full view (one click)
700
+ const toggleView = () => {
701
+ liveOutputDetailExpanded = !liveOutputDetailExpanded;
702
+ renderLiveOutputView();
703
+ };
704
+ if (viewToggleBtn) {
705
+ viewToggleBtn.addEventListener("click", toggleView);
706
+ }
707
+ // Initial render
708
+ updateLiveOutputViewToggle();
709
+ renderLiveOutputView();
710
+ }
711
+ /**
712
+ * Initialize the reason modal click handler.
713
+ */
714
+ function initReasonModal() {
715
+ const reasonEl = document.getElementById("ticket-flow-reason");
716
+ const modalOverlay = document.getElementById("reason-modal");
717
+ const modalContent = document.getElementById("reason-modal-content");
718
+ const closeBtn = document.getElementById("reason-modal-close");
719
+ if (!reasonEl || !modalOverlay || !modalContent)
720
+ return;
721
+ let closeModal = null;
722
+ const showReasonModal = () => {
723
+ if (!currentReasonFull || !reasonEl.classList.contains("has-details"))
724
+ return;
725
+ modalContent.textContent = currentReasonFull;
726
+ closeModal = openModal(modalOverlay, {
727
+ closeOnEscape: true,
728
+ closeOnOverlay: true,
729
+ returnFocusTo: reasonEl,
730
+ });
731
+ };
732
+ reasonEl.addEventListener("click", showReasonModal);
733
+ if (closeBtn) {
734
+ closeBtn.addEventListener("click", () => {
735
+ if (closeModal)
736
+ closeModal();
737
+ });
738
+ }
739
+ }
740
+ function els() {
741
+ return {
742
+ card: document.getElementById("ticket-card"),
743
+ status: document.getElementById("ticket-flow-status"),
744
+ run: document.getElementById("ticket-flow-run"),
745
+ current: document.getElementById("ticket-flow-current"),
746
+ turn: document.getElementById("ticket-flow-turn"),
747
+ elapsed: document.getElementById("ticket-flow-elapsed"),
748
+ progress: document.getElementById("ticket-flow-progress"),
749
+ reason: document.getElementById("ticket-flow-reason"),
750
+ lastActivity: document.getElementById("ticket-flow-last-activity"),
751
+ stalePill: document.getElementById("ticket-flow-stale"),
752
+ reconnectBtn: document.getElementById("ticket-flow-reconnect"),
753
+ workerStatus: document.getElementById("ticket-flow-worker"),
754
+ workerPill: document.getElementById("ticket-flow-worker-pill"),
755
+ recoverBtn: document.getElementById("ticket-flow-recover"),
756
+ metaDetails: document.getElementById("ticket-meta-details"),
757
+ dir: document.getElementById("ticket-flow-dir"),
758
+ tickets: document.getElementById("ticket-flow-tickets"),
759
+ history: document.getElementById("ticket-dispatch-history"),
760
+ dispatchNote: document.getElementById("ticket-dispatch-note"),
761
+ dispatchPanel: document.getElementById("dispatch-panel"),
762
+ dispatchPanelToggle: document.getElementById("dispatch-panel-toggle"),
763
+ dispatchMiniList: document.getElementById("dispatch-mini-list"),
764
+ bootstrapBtn: document.getElementById("ticket-flow-bootstrap"),
765
+ resumeBtn: document.getElementById("ticket-flow-resume"),
766
+ refreshBtn: document.getElementById("ticket-flow-refresh"),
767
+ stopBtn: document.getElementById("ticket-flow-stop"),
768
+ restartBtn: document.getElementById("ticket-flow-restart"),
769
+ archiveBtn: document.getElementById("ticket-flow-archive"),
770
+ overflowToggle: document.getElementById("ticket-overflow-toggle"),
771
+ overflowDropdown: document.getElementById("ticket-overflow-dropdown"),
772
+ overflowNew: document.getElementById("ticket-overflow-new"),
773
+ overflowRestart: document.getElementById("ticket-overflow-restart"),
774
+ overflowArchive: document.getElementById("ticket-overflow-archive"),
775
+ };
776
+ }
777
+ function setButtonsDisabled(disabled) {
778
+ const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
779
+ [bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn].forEach((btn) => {
780
+ if (btn)
781
+ btn.disabled = disabled;
782
+ });
783
+ }
784
+ /**
785
+ * Updates the selected class on ticket items based on selectedTicketPath.
786
+ */
787
+ function updateSelectedTicket(path) {
788
+ selectedTicketPath = path;
789
+ const ticketList = document.getElementById("ticket-flow-tickets");
790
+ if (!ticketList)
791
+ return;
792
+ const items = ticketList.querySelectorAll(".ticket-item");
793
+ items.forEach((item) => {
794
+ const ticketPath = item.getAttribute("data-ticket-path");
795
+ if (ticketPath === path) {
796
+ item.classList.add("selected");
797
+ }
798
+ else {
799
+ item.classList.remove("selected");
800
+ }
801
+ });
802
+ }
803
+ /**
804
+ * Updates the scroll fade indicator on ticket panels.
805
+ * Adds 'has-scroll-bottom' class when content is scrollable and not at bottom.
806
+ */
807
+ function updateScrollFade() {
808
+ const ticketList = document.getElementById("ticket-flow-tickets");
809
+ const dispatchHistory = document.getElementById("ticket-dispatch-history");
810
+ [ticketList, dispatchHistory].forEach((list) => {
811
+ if (!list)
812
+ return;
813
+ const panel = list.closest(".ticket-panel");
814
+ if (!panel)
815
+ return;
816
+ // Check if scrollable and not scrolled to bottom
817
+ const hasScrollableContent = list.scrollHeight > list.clientHeight;
818
+ const isNotAtBottom = list.scrollTop + list.clientHeight < list.scrollHeight - 10;
819
+ if (hasScrollableContent && isNotAtBottom) {
820
+ panel.classList.add("has-scroll-bottom");
821
+ }
822
+ else {
823
+ panel.classList.remove("has-scroll-bottom");
824
+ }
825
+ });
826
+ }
827
+ function truncate(text, max = 100) {
828
+ if (text.length <= max)
829
+ return text;
830
+ return `${text.slice(0, max).trim()}…`;
831
+ }
832
+ function renderTickets(data) {
833
+ ticketListCache = data;
834
+ const { tickets, dir } = els();
835
+ if (dir)
836
+ dir.textContent = data?.ticket_dir || "–";
837
+ if (!tickets)
838
+ return;
839
+ tickets.innerHTML = "";
840
+ const list = (data?.tickets || []);
841
+ ticketsExist = list.length > 0;
842
+ // Update progress bar
843
+ const progressBar = document.getElementById("ticket-progress-bar");
844
+ const progressFill = document.getElementById("ticket-progress-fill");
845
+ if (progressBar && progressFill) {
846
+ if (list.length === 0) {
847
+ progressBar.classList.add("hidden");
848
+ }
849
+ else {
850
+ progressBar.classList.remove("hidden");
851
+ const doneCount = list.filter((t) => Boolean((t.frontmatter || {})?.done)).length;
852
+ const percent = Math.round((doneCount / list.length) * 100);
853
+ progressFill.style.width = `${percent}%`;
854
+ progressBar.title = `${doneCount} of ${list.length} tickets done`;
855
+ }
856
+ }
857
+ if (!list.length) {
858
+ tickets.textContent = "No tickets found. Start the ticket flow to create TICKET-001.md.";
859
+ return;
860
+ }
861
+ list.forEach((ticket) => {
862
+ const item = document.createElement("div");
863
+ const fm = (ticket.frontmatter || {});
864
+ const done = Boolean(fm?.done);
865
+ // Check if this ticket is currently being worked on
866
+ const isActive = Boolean(currentActiveTicket &&
867
+ ticket.path === currentActiveTicket &&
868
+ isFlowActiveStatus(currentFlowStatus));
869
+ item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} ${selectedTicketPath === ticket.path ? "selected" : ""} clickable`;
870
+ item.title = "Click to edit";
871
+ item.setAttribute("data-ticket-path", ticket.path || "");
872
+ // Make ticket item clickable to open editor
873
+ item.addEventListener("click", () => {
874
+ updateSelectedTicket(ticket.path || null);
875
+ openTicketEditor(ticket);
876
+ });
877
+ const head = document.createElement("div");
878
+ head.className = "ticket-item-head";
879
+ // Extract ticket number from path (e.g., "TICKET-001" from ".codex-autorunner/tickets/TICKET-001.md")
880
+ const ticketPath = ticket.path || "";
881
+ const ticketMatch = ticketPath.match(/TICKET-\d+/);
882
+ const ticketNumber = ticketMatch ? ticketMatch[0] : "TICKET";
883
+ const ticketTitle = fm?.title ? String(fm.title) : "";
884
+ const name = document.createElement("span");
885
+ name.className = "ticket-name";
886
+ // Split number and title into separate spans for responsive control
887
+ const numSpan = document.createElement("span");
888
+ numSpan.className = "ticket-num";
889
+ // Extract just the number (e.g., "001" from "TICKET-001")
890
+ const numMatch = ticketNumber.match(/\d+/);
891
+ numSpan.textContent = numMatch ? numMatch[0] : ticketNumber;
892
+ name.appendChild(numSpan);
893
+ if (ticketTitle) {
894
+ const titleSpan = document.createElement("span");
895
+ titleSpan.className = "ticket-title-text";
896
+ titleSpan.textContent = `: ${ticketTitle}`;
897
+ name.appendChild(titleSpan);
898
+ }
899
+ // Set full text as title attribute for tooltip on hover
900
+ item.title = ticketTitle ? `${ticketNumber}: ${ticketTitle}` : ticketNumber;
901
+ head.appendChild(name);
902
+ // Badge container for status + agent badges
903
+ const badges = document.createElement("span");
904
+ badges.className = "ticket-badges";
905
+ // Add WORKING badge for active ticket (to the left of agent badge)
906
+ if (isActive) {
907
+ const workingBadge = document.createElement("span");
908
+ workingBadge.className = "ticket-working-badge";
909
+ // Text content used on middle responsive view; CSS hides text on desktop/mobile
910
+ const workingText = document.createElement("span");
911
+ workingText.className = "badge-text";
912
+ workingText.textContent = "Working";
913
+ workingBadge.appendChild(workingText);
914
+ badges.appendChild(workingBadge);
915
+ }
916
+ // Add DONE badge for completed tickets
917
+ if (done && !isActive) {
918
+ const doneBadge = document.createElement("span");
919
+ doneBadge.className = "ticket-done-badge";
920
+ // Text content used on middle responsive view; CSS hides text on desktop/mobile
921
+ const doneText = document.createElement("span");
922
+ doneText.className = "badge-text";
923
+ doneText.textContent = "Done";
924
+ doneBadge.appendChild(doneText);
925
+ badges.appendChild(doneBadge);
926
+ }
927
+ const agent = document.createElement("span");
928
+ agent.className = "ticket-agent";
929
+ agent.textContent = fm?.agent || "codex";
930
+ badges.appendChild(agent);
931
+ head.appendChild(badges);
932
+ item.appendChild(head);
933
+ if (ticket.errors && ticket.errors.length) {
934
+ const errors = document.createElement("div");
935
+ errors.className = "ticket-errors";
936
+ errors.textContent = `Frontmatter issues: ${ticket.errors.join("; ")}`;
937
+ item.appendChild(errors);
938
+ }
939
+ if (ticket.body) {
940
+ const body = document.createElement("div");
941
+ body.className = "ticket-body";
942
+ body.textContent = truncate(ticket.body.replace(/\s+/g, " ").trim());
943
+ item.appendChild(body);
944
+ }
945
+ tickets.appendChild(item);
946
+ });
947
+ // Update scroll fade indicator after rendering
948
+ updateScrollFade();
949
+ }
950
+ function renderDispatchHistory(runId, data) {
951
+ const { history, dispatchNote } = els();
952
+ if (!history)
953
+ return;
954
+ history.innerHTML = "";
955
+ const { dispatchMiniList } = els();
956
+ if (!runId) {
957
+ history.textContent = "Start the ticket flow to see agent dispatches.";
958
+ if (dispatchNote)
959
+ dispatchNote.textContent = "–";
960
+ if (dispatchMiniList)
961
+ dispatchMiniList.innerHTML = "";
962
+ return;
963
+ }
964
+ const entries = (data?.history || []);
965
+ if (!entries.length) {
966
+ history.textContent = "No dispatches yet.";
967
+ if (dispatchNote)
968
+ dispatchNote.textContent = "–";
969
+ if (dispatchMiniList)
970
+ dispatchMiniList.innerHTML = "";
971
+ return;
972
+ }
973
+ if (dispatchNote)
974
+ dispatchNote.textContent = `Latest #${entries[0]?.seq ?? "–"}`;
975
+ // Also render mini list for collapsed panel view
976
+ renderDispatchMiniList(entries);
977
+ entries.forEach((entry, index) => {
978
+ const dispatch = entry.dispatch;
979
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
980
+ const isHandoff = dispatch?.mode === "pause";
981
+ const isNotify = dispatch?.mode === "notify";
982
+ // Expand only the first (newest) dispatch by default - entries are newest-first
983
+ const isFirst = index === 0;
984
+ const isCollapsed = !isFirst;
985
+ const container = document.createElement("div");
986
+ container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""}${isHandoff ? " pause" : ""}${isNotify ? " notify" : ""}${isCollapsed ? " collapsed" : ""}`;
987
+ // Reddit-style thin collapse bar on the left
988
+ const collapseBar = document.createElement("div");
989
+ collapseBar.className = "dispatch-collapse-bar";
990
+ collapseBar.title = isCollapsed ? "Click to expand" : "Click to collapse";
991
+ collapseBar.setAttribute("role", "button");
992
+ collapseBar.setAttribute("tabindex", "0");
993
+ collapseBar.setAttribute("aria-label", isCollapsed ? "Expand dispatch" : "Collapse dispatch");
994
+ collapseBar.setAttribute("aria-expanded", String(!isCollapsed));
995
+ const toggleCollapse = () => {
996
+ container.classList.toggle("collapsed");
997
+ const isNowCollapsed = container.classList.contains("collapsed");
998
+ collapseBar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
999
+ collapseBar.setAttribute("aria-expanded", String(!isNowCollapsed));
1000
+ collapseBar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
1001
+ };
1002
+ collapseBar.addEventListener("click", (e) => {
1003
+ e.stopPropagation();
1004
+ toggleCollapse();
1005
+ });
1006
+ collapseBar.addEventListener("keydown", (e) => {
1007
+ if (e.key === "Enter" || e.key === " ") {
1008
+ e.preventDefault();
1009
+ toggleCollapse();
1010
+ }
1011
+ });
1012
+ // Content wrapper for header and body
1013
+ const contentWrapper = document.createElement("div");
1014
+ contentWrapper.className = "dispatch-content-wrapper";
1015
+ // Create collapsible structure
1016
+ const header = document.createElement("div");
1017
+ header.className = "dispatch-header";
1018
+ // Make header clickable to toggle collapse
1019
+ header.addEventListener("click", (e) => {
1020
+ // Don't toggle if clicking on a link or navigating to inbox
1021
+ if (e.target.closest("a"))
1022
+ return;
1023
+ toggleCollapse();
1024
+ });
1025
+ // Header content area
1026
+ const headerContent = document.createElement("div");
1027
+ headerContent.className = "dispatch-header-content";
1028
+ headerContent.title = isTurnSummary ? "Agent turn output" : "Click header to expand/collapse";
1029
+ // Determine mode label
1030
+ let modeLabel;
1031
+ if (isTurnSummary) {
1032
+ modeLabel = "TURN";
1033
+ }
1034
+ else if (isHandoff) {
1035
+ modeLabel = "HANDOFF";
1036
+ }
1037
+ else {
1038
+ modeLabel = (dispatch?.mode || "notify").toUpperCase();
1039
+ }
1040
+ const head = document.createElement("div");
1041
+ head.className = "dispatch-item-head";
1042
+ const seq = document.createElement("span");
1043
+ seq.className = "ticket-name";
1044
+ seq.textContent = `#${entry.seq || "?"}`;
1045
+ const mode = document.createElement("span");
1046
+ mode.className = `ticket-agent${isTurnSummary ? " turn-summary-badge" : ""}`;
1047
+ mode.textContent = modeLabel;
1048
+ head.append(seq, mode);
1049
+ headerContent.appendChild(head);
1050
+ header.appendChild(headerContent);
1051
+ contentWrapper.appendChild(header);
1052
+ container.append(collapseBar, contentWrapper);
1053
+ // Add diff stats if present (for turn summaries)
1054
+ const diffStats = dispatch?.extra?.diff_stats;
1055
+ if (diffStats && (diffStats.insertions || diffStats.deletions)) {
1056
+ const statsEl = document.createElement("span");
1057
+ statsEl.className = "dispatch-diff-stats";
1058
+ const ins = diffStats.insertions || 0;
1059
+ const del = diffStats.deletions || 0;
1060
+ statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
1061
+ statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
1062
+ head.appendChild(statsEl);
1063
+ }
1064
+ // Add ticket reference if present
1065
+ const ticketId = dispatch?.extra?.ticket_id;
1066
+ if (ticketId) {
1067
+ // Extract ticket number from path (e.g., "TICKET-009" from ".codex-autorunner/tickets/TICKET-009.md")
1068
+ const ticketMatch = ticketId.match(/TICKET-\d+/);
1069
+ if (ticketMatch) {
1070
+ const ticketLabel = document.createElement("span");
1071
+ ticketLabel.className = "dispatch-ticket-ref";
1072
+ ticketLabel.textContent = ticketMatch[0];
1073
+ ticketLabel.title = ticketId;
1074
+ head.appendChild(ticketLabel);
1075
+ }
1076
+ }
1077
+ // Add timestamp
1078
+ const timeAgo = formatDispatchTime(entry.created_at);
1079
+ if (timeAgo) {
1080
+ const timeLabel = document.createElement("span");
1081
+ timeLabel.className = "dispatch-time";
1082
+ timeLabel.textContent = timeAgo;
1083
+ head.appendChild(timeLabel);
1084
+ }
1085
+ // Create collapsible body content
1086
+ const bodyWrapper = document.createElement("div");
1087
+ bodyWrapper.className = "dispatch-body-wrapper";
1088
+ if (entry.errors && entry.errors.length) {
1089
+ const err = document.createElement("div");
1090
+ err.className = "ticket-errors";
1091
+ err.textContent = entry.errors.join("; ");
1092
+ bodyWrapper.appendChild(err);
1093
+ }
1094
+ const title = dispatch?.title;
1095
+ if (title) {
1096
+ const titleEl = document.createElement("div");
1097
+ titleEl.className = "ticket-body ticket-dispatch-title";
1098
+ titleEl.textContent = title;
1099
+ bodyWrapper.appendChild(titleEl);
1100
+ }
1101
+ const bodyText = dispatch?.body;
1102
+ if (bodyText) {
1103
+ const body = document.createElement("div");
1104
+ body.className = "ticket-body ticket-dispatch-body messages-markdown";
1105
+ body.innerHTML = renderMarkdown(bodyText);
1106
+ bodyWrapper.appendChild(body);
1107
+ }
1108
+ const attachments = (entry.attachments || []);
1109
+ if (attachments.length) {
1110
+ const wrap = document.createElement("div");
1111
+ wrap.className = "ticket-attachments";
1112
+ attachments.forEach((att) => {
1113
+ if (!att.url)
1114
+ return;
1115
+ const link = document.createElement("a");
1116
+ link.href = resolvePath(att.url);
1117
+ link.textContent = att.name || att.rel_path || "attachment";
1118
+ link.target = "_blank";
1119
+ link.rel = "noreferrer noopener";
1120
+ link.title = att.path || "";
1121
+ wrap.appendChild(link);
1122
+ });
1123
+ bodyWrapper.appendChild(wrap);
1124
+ }
1125
+ contentWrapper.appendChild(bodyWrapper);
1126
+ history.appendChild(container);
1127
+ });
1128
+ // Update scroll fade indicator after rendering
1129
+ updateScrollFade();
1130
+ }
1131
+ const MAX_REASON_LENGTH = 60;
1132
+ /**
1133
+ * Get the full reason text (summary + details) for modal display.
1134
+ */
1135
+ function getFullReason(run) {
1136
+ if (!run)
1137
+ return null;
1138
+ const state = (run.state || {});
1139
+ const engine = (state.ticket_engine || {});
1140
+ const reason = engine.reason || run.error_message || "";
1141
+ const details = engine.reason_details || "";
1142
+ if (!reason && !details)
1143
+ return null;
1144
+ if (details) {
1145
+ return `${reason}\n\n${details}`.trim();
1146
+ }
1147
+ return reason;
1148
+ }
1149
+ /**
1150
+ * Get a truncated reason summary for display in the grid.
1151
+ * Also updates currentReasonFull for modal access.
1152
+ */
1153
+ function summarizeReason(run) {
1154
+ if (!run) {
1155
+ currentReasonFull = null;
1156
+ return "No ticket flow run yet.";
1157
+ }
1158
+ const state = (run.state || {});
1159
+ const engine = (state.ticket_engine || {});
1160
+ const fullReason = getFullReason(run);
1161
+ currentReasonFull = fullReason;
1162
+ const reasonSummary = typeof run.reason_summary === "string" ? run.reason_summary : "";
1163
+ const useSummary = run.status === "paused" || run.status === "failed" || run.status === "stopped";
1164
+ const shortReason = (useSummary && reasonSummary ? reasonSummary : "") ||
1165
+ engine.reason ||
1166
+ run.error_message ||
1167
+ (engine.current_ticket ? `Working on ${engine.current_ticket}` : "") ||
1168
+ run.status ||
1169
+ "";
1170
+ // Truncate if too long
1171
+ if (shortReason.length > MAX_REASON_LENGTH) {
1172
+ return shortReason.slice(0, MAX_REASON_LENGTH - 3) + "...";
1173
+ }
1174
+ return shortReason;
1175
+ }
1176
+ async function loadTicketFiles(ctx) {
1177
+ const { tickets } = els();
1178
+ const isInitial = ticketListRefresh.getSignature() === null;
1179
+ if (tickets && isInitial) {
1180
+ tickets.textContent = "Loading tickets…";
1181
+ }
1182
+ try {
1183
+ await ticketListRefresh.refresh(async () => {
1184
+ const data = (await api("/api/flows/ticket_flow/tickets"));
1185
+ return {
1186
+ ticket_dir: data.ticket_dir,
1187
+ tickets: data.tickets,
1188
+ activeTicket: currentActiveTicket,
1189
+ flowStatus: currentFlowStatus,
1190
+ };
1191
+ }, { reason: ctx?.reason === "manual" ? "manual" : "background" });
1192
+ }
1193
+ catch (err) {
1194
+ ticketListRefresh.reset();
1195
+ ticketListCache = null;
1196
+ preserveScroll(tickets, () => {
1197
+ renderTickets(null);
1198
+ }, { restoreOnNextFrame: true });
1199
+ flash(err.message || "Failed to load tickets", "error");
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Open a ticket by its index
1204
+ */
1205
+ async function openTicketByIndex(index) {
1206
+ try {
1207
+ const data = (await api("/api/flows/ticket_flow/tickets"));
1208
+ const ticket = data.tickets?.find((t) => t.index === index);
1209
+ if (ticket) {
1210
+ openTicketEditor(ticket);
1211
+ }
1212
+ else {
1213
+ flash(`Ticket TICKET-${String(index).padStart(3, "0")} not found`, "error");
1214
+ }
1215
+ }
1216
+ catch (err) {
1217
+ flash(`Failed to open ticket: ${err.message}`, "error");
1218
+ }
1219
+ }
1220
+ async function loadDispatchHistory(runId, ctx) {
1221
+ const { history } = els();
1222
+ const runChanged = dispatchHistoryRunId !== runId;
1223
+ if (!runId) {
1224
+ renderDispatchHistory(null, null);
1225
+ dispatchHistoryRefresh.reset();
1226
+ dispatchHistoryRunId = null;
1227
+ return;
1228
+ }
1229
+ if (runChanged) {
1230
+ dispatchHistoryRunId = runId;
1231
+ dispatchHistoryRefresh.reset();
1232
+ }
1233
+ const isInitial = dispatchHistoryRefresh.getSignature() === null;
1234
+ if (history && isInitial) {
1235
+ history.textContent = "Loading dispatch history…";
1236
+ }
1237
+ try {
1238
+ await dispatchHistoryRefresh.refresh(async () => {
1239
+ const data = (await api(`/api/flows/${runId}/dispatch_history`));
1240
+ return {
1241
+ runId,
1242
+ history: data.history,
1243
+ };
1244
+ }, {
1245
+ reason: ctx?.reason === "manual" ? "manual" : "background",
1246
+ force: runChanged,
1247
+ });
1248
+ }
1249
+ catch (err) {
1250
+ dispatchHistoryRefresh.reset();
1251
+ preserveScroll(history, () => {
1252
+ renderDispatchHistory(runId, null);
1253
+ }, { restoreOnNextFrame: true });
1254
+ flash(err.message || "Failed to load dispatch history", "error");
1255
+ }
1256
+ }
1257
+ async function loadTicketFlow(ctx) {
1258
+ const { status, run, current, turn, elapsed, progress, reason, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, resumeBtn, bootstrapBtn, stopBtn, archiveBtn, refreshBtn, } = els();
1259
+ if (!isRepoHealthy()) {
1260
+ if (status)
1261
+ statusPill(status, "error");
1262
+ if (run)
1263
+ run.textContent = "–";
1264
+ if (current)
1265
+ current.textContent = "–";
1266
+ if (turn)
1267
+ turn.textContent = "–";
1268
+ if (elapsed)
1269
+ elapsed.textContent = "–";
1270
+ if (progress)
1271
+ progress.textContent = "–";
1272
+ if (lastActivity)
1273
+ lastActivity.textContent = "–";
1274
+ if (stalePill)
1275
+ stalePill.style.display = "none";
1276
+ if (reconnectBtn)
1277
+ reconnectBtn.style.display = "none";
1278
+ if (workerStatus)
1279
+ workerStatus.textContent = "–";
1280
+ if (workerPill)
1281
+ workerPill.style.display = "none";
1282
+ if (recoverBtn)
1283
+ recoverBtn.style.display = "none";
1284
+ if (reason)
1285
+ reason.textContent = "Repo offline or uninitialized.";
1286
+ setButtonsDisabled(true);
1287
+ setButtonLoading(refreshBtn, false);
1288
+ stopElapsedTimer();
1289
+ stopLastActivityTimer();
1290
+ disconnectEventStream();
1291
+ return;
1292
+ }
1293
+ const showRefreshIndicator = ticketFlowLoaded;
1294
+ if (showRefreshIndicator) {
1295
+ setButtonLoading(refreshBtn, true);
1296
+ }
1297
+ try {
1298
+ const runs = (await api("/api/flows/runs?flow_type=ticket_flow"));
1299
+ // Only consider the newest run - if it's terminal, flow is idle.
1300
+ // This matches the backend's _active_or_paused_run() logic which only checks runs[0].
1301
+ // Using find() would incorrectly pick up older paused runs when a newer run has completed.
1302
+ const newest = runs?.[0] || null;
1303
+ // Keep the newest run even if terminal, so we can archive it or see its final state
1304
+ const latest = newest;
1305
+ currentRunId = latest?.id || null;
1306
+ currentFlowStatus = latest?.status || null;
1307
+ // Extract ticket engine state
1308
+ const ticketEngine = latest?.state?.ticket_engine;
1309
+ // The server now provides an effective current_ticket during in-flight steps.
1310
+ // Trust the API value even when null so we don't show stale DONE+WORKING between steps.
1311
+ const apiActiveTicket = ticketEngine?.current_ticket || null;
1312
+ currentActiveTicket = apiActiveTicket;
1313
+ const ticketTurns = ticketEngine?.ticket_turns ?? null;
1314
+ const totalTurns = ticketEngine?.total_turns ?? null;
1315
+ if (status)
1316
+ statusPill(status, latest?.status || "idle");
1317
+ if (run)
1318
+ run.textContent = latest?.id || "–";
1319
+ if (current)
1320
+ current.textContent = currentActiveTicket || "–";
1321
+ // Display turn counter
1322
+ if (turn) {
1323
+ if (ticketTurns !== null && isFlowActiveStatus(currentFlowStatus)) {
1324
+ turn.textContent = `${ticketTurns}${totalTurns !== null ? ` (${totalTurns} total)` : ""}`;
1325
+ }
1326
+ else {
1327
+ turn.textContent = "–";
1328
+ }
1329
+ }
1330
+ // Handle elapsed time
1331
+ if (latest?.started_at && (latest.status === "running" || latest.status === "pending")) {
1332
+ flowStartedAt = new Date(latest.started_at);
1333
+ startElapsedTimer();
1334
+ }
1335
+ else {
1336
+ stopElapsedTimer();
1337
+ flowStartedAt = null;
1338
+ if (elapsed)
1339
+ elapsed.textContent = "–";
1340
+ }
1341
+ if (reason) {
1342
+ reason.textContent = summarizeReason(latest) || "–";
1343
+ // Add clickable class if there are details to show
1344
+ const state = (latest?.state || {});
1345
+ const engine = (state.ticket_engine || {});
1346
+ const hasDetails = Boolean(engine.reason_details ||
1347
+ (currentReasonFull && currentReasonFull.length > MAX_REASON_LENGTH));
1348
+ reason.classList.toggle("has-details", hasDetails);
1349
+ }
1350
+ lastKnownEventSeq = typeof latest?.last_event_seq === "number" ? latest.last_event_seq : null;
1351
+ if (currentRunId && typeof lastKnownEventSeq === "number") {
1352
+ setLastSeenSeq(currentRunId, lastKnownEventSeq);
1353
+ }
1354
+ updateLastActivityFromTimestamp(latest?.last_event_at || null);
1355
+ const isActive = latest?.status === "running" || latest?.status === "pending";
1356
+ const isStale = Boolean(isActive &&
1357
+ lastKnownEventAt &&
1358
+ Date.now() - lastKnownEventAt.getTime() > STALE_THRESHOLD_MS);
1359
+ if (stalePill)
1360
+ stalePill.style.display = isStale ? "" : "none";
1361
+ if (reconnectBtn) {
1362
+ reconnectBtn.style.display = isStale ? "" : "none";
1363
+ reconnectBtn.disabled = !currentRunId;
1364
+ }
1365
+ const worker = latest?.worker_health;
1366
+ const workerLabel = worker?.status
1367
+ ? `${worker.status}${worker.pid ? ` (pid ${worker.pid})` : ""}`
1368
+ : "–";
1369
+ if (workerStatus)
1370
+ workerStatus.textContent = workerLabel;
1371
+ const workerDead = Boolean(isActive &&
1372
+ worker &&
1373
+ worker.is_alive === false &&
1374
+ worker.status !== "absent");
1375
+ if (workerPill)
1376
+ workerPill.style.display = workerDead ? "" : "none";
1377
+ if (recoverBtn) {
1378
+ recoverBtn.style.display = workerDead ? "" : "none";
1379
+ recoverBtn.disabled = !currentRunId;
1380
+ }
1381
+ if (resumeBtn) {
1382
+ resumeBtn.disabled = !latest?.id || latest.status !== "paused";
1383
+ }
1384
+ if (stopBtn) {
1385
+ const stoppable = latest?.status === "running" || latest?.status === "pending";
1386
+ stopBtn.disabled = !latest?.id || !stoppable;
1387
+ }
1388
+ await loadTicketFiles(ctx);
1389
+ // Calculate and display ticket progress (scoped to tickets container only)
1390
+ if (progress) {
1391
+ const ticketsContainer = document.getElementById("ticket-flow-tickets");
1392
+ const doneCount = ticketsContainer?.querySelectorAll(".ticket-item.done").length ?? 0;
1393
+ const totalCount = ticketsContainer?.querySelectorAll(".ticket-item").length ?? 0;
1394
+ if (totalCount > 0) {
1395
+ progress.textContent = `${doneCount} of ${totalCount} done`;
1396
+ }
1397
+ else {
1398
+ progress.textContent = "–";
1399
+ }
1400
+ }
1401
+ // Connect/disconnect event stream based on flow status
1402
+ if (currentRunId && (latest?.status === "running" || latest?.status === "pending")) {
1403
+ // Only connect if not already connected to this run
1404
+ const isSameRun = eventSourceRunId === currentRunId;
1405
+ const isClosed = eventSource?.readyState === EventSource.CLOSED;
1406
+ if (!eventSource || !isSameRun || isClosed) {
1407
+ connectEventStream(currentRunId);
1408
+ startLastActivityTimer();
1409
+ }
1410
+ }
1411
+ else {
1412
+ disconnectEventStream();
1413
+ if (!lastKnownEventAt) {
1414
+ stopLastActivityTimer();
1415
+ if (lastActivity)
1416
+ lastActivity.textContent = "–";
1417
+ lastActivityTime = null;
1418
+ }
1419
+ }
1420
+ if (bootstrapBtn) {
1421
+ const busy = latest?.status === "running" || latest?.status === "pending";
1422
+ // Disable only if busy; bootstrap will create initial ticket when missing
1423
+ bootstrapBtn.disabled = busy;
1424
+ bootstrapBtn.textContent = busy ? "Running…" : "Start Ticket Flow";
1425
+ bootstrapBtn.title = busy ? "Ticket flow in progress" : "";
1426
+ }
1427
+ // Show restart button when flow is paused, stopping, or in terminal state (allows starting fresh)
1428
+ const { restartBtn, overflowRestart } = els();
1429
+ if (restartBtn) {
1430
+ const isPaused = latest?.status === "paused";
1431
+ const isStopping = latest?.status === "stopping";
1432
+ const isTerminal = latest?.status === "completed" ||
1433
+ latest?.status === "stopped" ||
1434
+ latest?.status === "failed";
1435
+ const canRestart = (isPaused || isStopping || isTerminal || workerDead) &&
1436
+ ticketsExist &&
1437
+ Boolean(currentRunId);
1438
+ restartBtn.style.display = canRestart ? "" : "none";
1439
+ restartBtn.disabled = !canRestart;
1440
+ if (overflowRestart) {
1441
+ overflowRestart.style.display = canRestart ? "" : "none";
1442
+ }
1443
+ }
1444
+ // Show archive button when flow is paused, stopping, or in terminal state and has tickets
1445
+ if (archiveBtn) {
1446
+ const isPaused = latest?.status === "paused";
1447
+ const isStopping = latest?.status === "stopping";
1448
+ const isTerminal = latest?.status === "completed" ||
1449
+ latest?.status === "stopped" ||
1450
+ latest?.status === "failed";
1451
+ const canArchive = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
1452
+ archiveBtn.style.display = canArchive ? "" : "none";
1453
+ archiveBtn.disabled = !canArchive;
1454
+ const { overflowArchive } = els();
1455
+ if (overflowArchive) {
1456
+ overflowArchive.style.display = canArchive ? "" : "none";
1457
+ }
1458
+ }
1459
+ await loadDispatchHistory(currentRunId, ctx);
1460
+ }
1461
+ catch (err) {
1462
+ if (reason)
1463
+ reason.textContent = err.message || "Ticket flow unavailable";
1464
+ flash(err.message || "Failed to load ticket flow state", "error");
1465
+ }
1466
+ finally {
1467
+ ticketFlowLoaded = true;
1468
+ if (showRefreshIndicator) {
1469
+ setButtonLoading(refreshBtn, false);
1470
+ }
1471
+ }
1472
+ }
1473
+ async function bootstrapTicketFlow() {
1474
+ const { bootstrapBtn } = els();
1475
+ if (!bootstrapBtn)
1476
+ return;
1477
+ if (!isRepoHealthy()) {
1478
+ flash("Repo offline; cannot start ticket flow.", "error");
1479
+ return;
1480
+ }
1481
+ setButtonsDisabled(true);
1482
+ bootstrapBtn.textContent = "Checking…";
1483
+ const startFlow = async () => {
1484
+ const res = (await api("/api/flows/ticket_flow/bootstrap", {
1485
+ method: "POST",
1486
+ body: {},
1487
+ }));
1488
+ currentRunId = res?.id || null;
1489
+ if (res?.state?.hint === "active_run_reused") {
1490
+ flash("Ticket flow already running; continuing existing run", "info");
1491
+ }
1492
+ else {
1493
+ flash("Ticket flow started");
1494
+ clearLiveOutput(); // Clear output for new run
1495
+ }
1496
+ await loadTicketFlow();
1497
+ };
1498
+ const seedIssueFromGithub = async (issueRef) => {
1499
+ await api("/api/flows/ticket_flow/seed-issue", {
1500
+ method: "POST",
1501
+ body: { issue_ref: issueRef },
1502
+ });
1503
+ flash("ISSUE.md created from GitHub", "success");
1504
+ };
1505
+ const seedIssueFromPlan = async (planText) => {
1506
+ await api("/api/flows/ticket_flow/seed-issue", {
1507
+ method: "POST",
1508
+ body: { plan_text: planText },
1509
+ });
1510
+ flash("ISSUE.md created from your input", "success");
1511
+ };
1512
+ const promptIssueRef = async (repo) => {
1513
+ const message = repo
1514
+ ? `Enter GitHub issue number or URL for ${repo}`
1515
+ : "Enter GitHub issue number or URL";
1516
+ const input = await inputModal(message, {
1517
+ placeholder: "#123 or https://github.com/org/repo/issues/123",
1518
+ confirmText: "Fetch issue",
1519
+ });
1520
+ const value = (input || "").trim();
1521
+ return value || null;
1522
+ };
1523
+ const promptPlanText = async () => {
1524
+ // Build a simple textarea modal dynamically to avoid new HTML templates.
1525
+ const overlay = document.createElement("div");
1526
+ overlay.className = "modal-overlay";
1527
+ overlay.hidden = true;
1528
+ const dialog = document.createElement("div");
1529
+ dialog.className = "modal-dialog";
1530
+ dialog.setAttribute("role", "dialog");
1531
+ dialog.setAttribute("aria-modal", "true");
1532
+ dialog.tabIndex = -1;
1533
+ const title = document.createElement("h3");
1534
+ title.textContent = "Describe the work";
1535
+ const textarea = document.createElement("textarea");
1536
+ textarea.placeholder = "Describe the scope/requirements to seed ISSUE.md";
1537
+ textarea.rows = 6;
1538
+ textarea.style.width = "100%";
1539
+ textarea.style.resize = "vertical";
1540
+ const actions = document.createElement("div");
1541
+ actions.className = "modal-actions";
1542
+ const cancel = document.createElement("button");
1543
+ cancel.className = "ghost";
1544
+ cancel.textContent = "Cancel";
1545
+ const submit = document.createElement("button");
1546
+ submit.className = "primary";
1547
+ submit.textContent = "Create ISSUE.md";
1548
+ actions.append(cancel, submit);
1549
+ dialog.append(title, textarea, actions);
1550
+ overlay.append(dialog);
1551
+ document.body.append(overlay);
1552
+ return await new Promise((resolve) => {
1553
+ let closeModal = null;
1554
+ const cleanup = () => {
1555
+ if (closeModal)
1556
+ closeModal();
1557
+ overlay.remove();
1558
+ };
1559
+ const finalize = (value) => {
1560
+ cleanup();
1561
+ resolve(value);
1562
+ };
1563
+ closeModal = openModal(overlay, {
1564
+ initialFocus: textarea,
1565
+ returnFocusTo: bootstrapBtn,
1566
+ onRequestClose: () => finalize(null),
1567
+ });
1568
+ submit.addEventListener("click", () => {
1569
+ finalize(textarea.value.trim() || null);
1570
+ });
1571
+ cancel.addEventListener("click", () => finalize(null));
1572
+ });
1573
+ };
1574
+ try {
1575
+ const check = (await api("/api/flows/ticket_flow/bootstrap-check", {
1576
+ method: "GET",
1577
+ }));
1578
+ if (check.status === "ready") {
1579
+ await startFlow();
1580
+ return;
1581
+ }
1582
+ if (check.status === "needs_issue") {
1583
+ if (check.github_available) {
1584
+ const issueRef = await promptIssueRef(check.repo);
1585
+ if (!issueRef) {
1586
+ flash("Bootstrap cancelled (no issue provided)", "info");
1587
+ return;
1588
+ }
1589
+ await seedIssueFromGithub(issueRef);
1590
+ }
1591
+ else {
1592
+ const planText = await promptPlanText();
1593
+ if (!planText) {
1594
+ flash("Bootstrap cancelled (no description provided)", "info");
1595
+ return;
1596
+ }
1597
+ await seedIssueFromPlan(planText);
1598
+ }
1599
+ await startFlow();
1600
+ return;
1601
+ }
1602
+ // Fallback: start normally
1603
+ await startFlow();
1604
+ }
1605
+ catch (err) {
1606
+ flash(err.message || "Failed to start ticket flow", "error");
1607
+ }
1608
+ finally {
1609
+ bootstrapBtn.textContent = "Start Ticket Flow";
1610
+ setButtonsDisabled(false);
1611
+ }
1612
+ }
1613
+ async function resumeTicketFlow() {
1614
+ const { resumeBtn } = els();
1615
+ if (!resumeBtn)
1616
+ return;
1617
+ if (!isRepoHealthy()) {
1618
+ flash("Repo offline; cannot resume ticket flow.", "error");
1619
+ return;
1620
+ }
1621
+ if (!currentRunId) {
1622
+ flash("No ticket flow run to resume", "info");
1623
+ return;
1624
+ }
1625
+ setButtonsDisabled(true);
1626
+ resumeBtn.textContent = "Resuming…";
1627
+ try {
1628
+ await api(`/api/flows/${currentRunId}/resume`, { method: "POST", body: {} });
1629
+ flash("Ticket flow resumed");
1630
+ await loadTicketFlow();
1631
+ }
1632
+ catch (err) {
1633
+ flash(err.message || "Failed to resume", "error");
1634
+ }
1635
+ finally {
1636
+ resumeBtn.textContent = "Resume";
1637
+ setButtonsDisabled(false);
1638
+ }
1639
+ }
1640
+ function reconnectTicketFlowStream() {
1641
+ if (!currentRunId) {
1642
+ flash("No ticket flow run to reconnect", "info");
1643
+ return;
1644
+ }
1645
+ const afterSeq = typeof lastKnownEventSeq === "number"
1646
+ ? lastKnownEventSeq
1647
+ : getLastSeenSeq(currentRunId);
1648
+ connectEventStream(currentRunId, afterSeq ?? undefined);
1649
+ flash("Reconnecting event stream", "info");
1650
+ }
1651
+ async function stopTicketFlow() {
1652
+ const { stopBtn } = els();
1653
+ if (!stopBtn)
1654
+ return;
1655
+ if (!isRepoHealthy()) {
1656
+ flash("Repo offline; cannot stop ticket flow.", "error");
1657
+ return;
1658
+ }
1659
+ if (!currentRunId) {
1660
+ flash("No ticket flow run to stop", "info");
1661
+ return;
1662
+ }
1663
+ setButtonsDisabled(true);
1664
+ stopBtn.textContent = "Stopping…";
1665
+ try {
1666
+ await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
1667
+ flash("Ticket flow stopping");
1668
+ await loadTicketFlow();
1669
+ }
1670
+ catch (err) {
1671
+ flash(err.message || "Failed to stop ticket flow", "error");
1672
+ }
1673
+ finally {
1674
+ stopBtn.textContent = "Stop";
1675
+ setButtonsDisabled(false);
1676
+ }
1677
+ }
1678
+ async function recoverTicketFlow() {
1679
+ const { recoverBtn } = els();
1680
+ if (!recoverBtn)
1681
+ return;
1682
+ if (!isRepoHealthy()) {
1683
+ flash("Repo offline; cannot recover ticket flow.", "error");
1684
+ return;
1685
+ }
1686
+ if (!currentRunId) {
1687
+ flash("No ticket flow run to recover", "info");
1688
+ return;
1689
+ }
1690
+ setButtonsDisabled(true);
1691
+ recoverBtn.textContent = "Recovering…";
1692
+ try {
1693
+ await api(`/api/flows/${currentRunId}/reconcile`, { method: "POST", body: {} });
1694
+ flash("Flow reconciled");
1695
+ await loadTicketFlow();
1696
+ }
1697
+ catch (err) {
1698
+ flash(err.message || "Failed to recover ticket flow", "error");
1699
+ }
1700
+ finally {
1701
+ recoverBtn.textContent = "Recover";
1702
+ setButtonsDisabled(false);
1703
+ }
1704
+ }
1705
+ async function restartTicketFlow() {
1706
+ const { restartBtn } = els();
1707
+ if (!restartBtn)
1708
+ return;
1709
+ if (!isRepoHealthy()) {
1710
+ flash("Repo offline; cannot restart ticket flow.", "error");
1711
+ return;
1712
+ }
1713
+ if (!ticketsExist) {
1714
+ flash("Create a ticket first before restarting the flow.", "error");
1715
+ return;
1716
+ }
1717
+ if (!confirm("Restart ticket flow? This will stop the current run and start a new one.")) {
1718
+ return;
1719
+ }
1720
+ setButtonsDisabled(true);
1721
+ restartBtn.textContent = "Restarting…";
1722
+ try {
1723
+ // Stop the current run first if it exists
1724
+ if (currentRunId) {
1725
+ await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
1726
+ }
1727
+ // Start a new run with force_new to bypass reuse logic
1728
+ const res = (await api("/api/flows/ticket_flow/bootstrap", {
1729
+ method: "POST",
1730
+ body: { metadata: { force_new: true } },
1731
+ }));
1732
+ currentRunId = res?.id || null;
1733
+ flash("Ticket flow restarted");
1734
+ clearLiveOutput();
1735
+ await loadTicketFlow();
1736
+ }
1737
+ catch (err) {
1738
+ flash(err.message || "Failed to restart ticket flow", "error");
1739
+ }
1740
+ finally {
1741
+ restartBtn.textContent = "Restart";
1742
+ setButtonsDisabled(false);
1743
+ }
1744
+ }
1745
+ async function archiveTicketFlow() {
1746
+ const { archiveBtn, reason } = els();
1747
+ if (!archiveBtn)
1748
+ return;
1749
+ if (!isRepoHealthy()) {
1750
+ flash("Repo offline; cannot archive ticket flow.", "error");
1751
+ return;
1752
+ }
1753
+ if (!currentRunId) {
1754
+ flash("No ticket flow run to archive", "info");
1755
+ return;
1756
+ }
1757
+ if (!confirm("Archive all tickets from this flow? They will be moved to the run's artifact directory.")) {
1758
+ return;
1759
+ }
1760
+ setButtonsDisabled(true);
1761
+ archiveBtn.textContent = "Archiving…";
1762
+ try {
1763
+ // Force archive if flow is stuck in stopping or paused state
1764
+ const force = currentFlowStatus === "stopping" || currentFlowStatus === "paused";
1765
+ const res = (await api(`/api/flows/${currentRunId}/archive?force=${force}`, {
1766
+ method: "POST",
1767
+ body: {},
1768
+ }));
1769
+ const count = res?.tickets_archived ?? 0;
1770
+ flash(`Archived ${count} ticket${count !== 1 ? "s" : ""}`);
1771
+ clearLiveOutput();
1772
+ // Reset all state variables
1773
+ currentRunId = null;
1774
+ currentFlowStatus = null;
1775
+ currentActiveTicket = null;
1776
+ currentReasonFull = null;
1777
+ // Reset all UI elements to idle state directly (avoid re-fetching stale data)
1778
+ const { status, run, current, turn, elapsed, progress, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, bootstrapBtn, resumeBtn, stopBtn, restartBtn, archiveBtn } = els();
1779
+ if (status)
1780
+ statusPill(status, "idle");
1781
+ if (run)
1782
+ run.textContent = "–";
1783
+ if (current)
1784
+ current.textContent = "–";
1785
+ if (turn)
1786
+ turn.textContent = "–";
1787
+ if (elapsed)
1788
+ elapsed.textContent = "–";
1789
+ if (progress)
1790
+ progress.textContent = "–";
1791
+ if (lastActivity)
1792
+ lastActivity.textContent = "–";
1793
+ if (stalePill)
1794
+ stalePill.style.display = "none";
1795
+ if (reconnectBtn)
1796
+ reconnectBtn.style.display = "none";
1797
+ if (workerStatus)
1798
+ workerStatus.textContent = "–";
1799
+ if (workerPill)
1800
+ workerPill.style.display = "none";
1801
+ if (recoverBtn)
1802
+ recoverBtn.style.display = "none";
1803
+ if (reason) {
1804
+ reason.textContent = "No ticket flow run yet.";
1805
+ reason.classList.remove("has-details");
1806
+ }
1807
+ renderDispatchHistory(null, null);
1808
+ // Stop timers and disconnect event stream
1809
+ disconnectEventStream();
1810
+ stopElapsedTimer();
1811
+ stopLastActivityTimer();
1812
+ lastActivityTime = null;
1813
+ // Update button states for no active run
1814
+ if (bootstrapBtn) {
1815
+ bootstrapBtn.disabled = false;
1816
+ bootstrapBtn.textContent = "Start Ticket Flow";
1817
+ bootstrapBtn.title = "";
1818
+ }
1819
+ if (resumeBtn)
1820
+ resumeBtn.disabled = true;
1821
+ if (stopBtn)
1822
+ stopBtn.disabled = true;
1823
+ if (restartBtn)
1824
+ restartBtn.style.display = "none";
1825
+ const { overflowRestart, overflowArchive } = els();
1826
+ if (overflowRestart)
1827
+ overflowRestart.style.display = "none";
1828
+ if (archiveBtn)
1829
+ archiveBtn.style.display = "none";
1830
+ if (overflowArchive)
1831
+ overflowArchive.style.display = "none";
1832
+ // Refresh inbox badge and ticket list (tickets were archived/moved)
1833
+ void refreshBell();
1834
+ await loadTicketFiles();
1835
+ }
1836
+ catch (err) {
1837
+ flash(err.message || "Failed to archive ticket flow", "error");
1838
+ }
1839
+ finally {
1840
+ if (archiveBtn) {
1841
+ archiveBtn.textContent = "Archive Flow";
1842
+ }
1843
+ setButtonsDisabled(false);
1844
+ }
1845
+ }
1846
+ export function initTicketFlow() {
1847
+ const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
1848
+ if (!card || card.dataset.ticketInitialized === "1")
1849
+ return;
1850
+ card.dataset.ticketInitialized = "1";
1851
+ if (bootstrapBtn)
1852
+ bootstrapBtn.addEventListener("click", bootstrapTicketFlow);
1853
+ if (resumeBtn)
1854
+ resumeBtn.addEventListener("click", resumeTicketFlow);
1855
+ if (stopBtn)
1856
+ stopBtn.addEventListener("click", stopTicketFlow);
1857
+ if (restartBtn)
1858
+ restartBtn.addEventListener("click", restartTicketFlow);
1859
+ if (archiveBtn)
1860
+ archiveBtn.addEventListener("click", archiveTicketFlow);
1861
+ if (reconnectBtn)
1862
+ reconnectBtn.addEventListener("click", reconnectTicketFlowStream);
1863
+ if (recoverBtn)
1864
+ recoverBtn.addEventListener("click", recoverTicketFlow);
1865
+ if (refreshBtn)
1866
+ refreshBtn.addEventListener("click", () => {
1867
+ void loadTicketFlow({ reason: "manual" });
1868
+ });
1869
+ const { overflowToggle, overflowDropdown, overflowNew, overflowRestart, overflowArchive } = els();
1870
+ if (overflowToggle && overflowDropdown) {
1871
+ const toggleMenu = (e) => {
1872
+ e.stopPropagation();
1873
+ const isHidden = overflowDropdown.classList.contains("hidden");
1874
+ overflowDropdown.classList.toggle("hidden", !isHidden);
1875
+ };
1876
+ overflowToggle.addEventListener("click", toggleMenu);
1877
+ overflowToggle.addEventListener("touchend", (e) => {
1878
+ e.preventDefault(); // Prevent ghost click
1879
+ toggleMenu(e);
1880
+ });
1881
+ // Close on outside click
1882
+ document.addEventListener("click", (e) => {
1883
+ if (!overflowDropdown.classList.contains("hidden") &&
1884
+ !overflowToggle.contains(e.target) &&
1885
+ !overflowDropdown.contains(e.target)) {
1886
+ overflowDropdown.classList.add("hidden");
1887
+ }
1888
+ });
1889
+ }
1890
+ if (overflowNew) {
1891
+ overflowNew.addEventListener("click", () => {
1892
+ const newBtn = document.getElementById("ticket-new-btn");
1893
+ newBtn?.click();
1894
+ overflowDropdown?.classList.add("hidden");
1895
+ });
1896
+ }
1897
+ if (overflowRestart) {
1898
+ overflowRestart.addEventListener("click", () => {
1899
+ void restartTicketFlow();
1900
+ overflowDropdown?.classList.add("hidden");
1901
+ });
1902
+ }
1903
+ if (overflowArchive) {
1904
+ overflowArchive.addEventListener("click", () => {
1905
+ void archiveTicketFlow();
1906
+ overflowDropdown?.classList.add("hidden");
1907
+ });
1908
+ }
1909
+ // Initialize reason click handler for modal
1910
+ initReasonModal();
1911
+ // Initialize live output panel
1912
+ initLiveOutputPanel();
1913
+ // Initialize dispatch panel toggle for medium screens
1914
+ initDispatchPanelToggle();
1915
+ // Set up scroll listeners for fade indicator
1916
+ const ticketList = document.getElementById("ticket-flow-tickets");
1917
+ const dispatchHistory = document.getElementById("ticket-dispatch-history");
1918
+ [ticketList, dispatchHistory].forEach((el) => {
1919
+ if (el) {
1920
+ el.addEventListener("scroll", updateScrollFade, { passive: true });
1921
+ }
1922
+ });
1923
+ const newThreadBtn = document.getElementById("ticket-chat-new-thread");
1924
+ if (newThreadBtn) {
1925
+ newThreadBtn.addEventListener("click", async () => {
1926
+ const { startNewTicketChatThread } = await import("./ticketChatActions.js");
1927
+ await startNewTicketChatThread();
1928
+ });
1929
+ }
1930
+ // Initialize the ticket editor modal
1931
+ initTicketEditor();
1932
+ loadTicketFlow();
1933
+ registerAutoRefresh("ticket-flow", {
1934
+ callback: async (ctx) => {
1935
+ await loadTicketFlow(ctx);
1936
+ },
1937
+ tabId: "tickets",
1938
+ interval: CONSTANTS.UI?.AUTO_REFRESH_INTERVAL ||
1939
+ 15000,
1940
+ refreshOnActivation: true,
1941
+ immediate: false,
1942
+ });
1943
+ subscribe("repo:health", (payload) => {
1944
+ const status = payload?.status || "";
1945
+ if (status === "ok" || status === "degraded") {
1946
+ void loadTicketFlow();
1947
+ }
1948
+ });
1949
+ // Refresh ticket list when tickets are updated (from editor)
1950
+ subscribe("tickets:updated", () => {
1951
+ void loadTicketFiles();
1952
+ });
1953
+ // Update selection when editor opens a ticket
1954
+ subscribe("ticket-editor:opened", (payload) => {
1955
+ const data = payload;
1956
+ if (data?.path) {
1957
+ updateSelectedTicket(data.path);
1958
+ return;
1959
+ }
1960
+ if (data?.index != null && ticketListCache?.tickets?.length) {
1961
+ const match = ticketListCache.tickets.find((ticket) => ticket.index === data.index);
1962
+ if (match?.path) {
1963
+ updateSelectedTicket(match.path);
1964
+ }
1965
+ }
1966
+ });
1967
+ // Clear selection when editor is closed
1968
+ subscribe("ticket-editor:closed", () => {
1969
+ updateSelectedTicket(null);
1970
+ });
1971
+ // Handle browser navigation (back/forward)
1972
+ window.addEventListener("popstate", () => {
1973
+ const params = getUrlParams();
1974
+ const ticketIndex = params.get("ticket");
1975
+ if (ticketIndex) {
1976
+ void openTicketByIndex(parseInt(ticketIndex, 10));
1977
+ }
1978
+ else {
1979
+ closeTicketEditor();
1980
+ }
1981
+ });
1982
+ // Check URL for ticket param on initial load
1983
+ const params = getUrlParams();
1984
+ const ticketIndex = params.get("ticket");
1985
+ if (ticketIndex) {
1986
+ void openTicketByIndex(parseInt(ticketIndex, 10));
1987
+ }
1988
+ }