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
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
- import { api, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal } from "./utils.js";
3
- import { activateTab } from "./tabs.js";
2
+ import { api, confirmModal, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal, inputModal, setButtonLoading, } from "./utils.js";
3
+ // Note: activateTab removed - header now used for collapse, not inbox navigation
4
4
  import { registerAutoRefresh } from "./autoRefresh.js";
5
5
  import { CONSTANTS } from "./constants.js";
6
6
  import { subscribe } from "./bus.js";
@@ -9,15 +9,55 @@ import { closeTicketEditor, initTicketEditor, openTicketEditor } from "./ticketE
9
9
  import { parseAppServerEvent } from "./agentEvents.js";
10
10
  import { summarizeEvents, renderCompactSummary, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
11
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
+ }
12
48
  let currentRunId = null;
13
49
  let ticketsExist = false;
14
50
  let currentActiveTicket = null;
15
51
  let currentFlowStatus = null;
52
+ let selectedTicketPath = null;
16
53
  let elapsedTimerId = null;
17
54
  let flowStartedAt = null;
18
55
  let eventSource = null;
56
+ let eventSourceRunId = null;
19
57
  let lastActivityTime = null;
20
58
  let lastActivityTimerId = null;
59
+ let lastKnownEventSeq = null;
60
+ let lastKnownEventAt = null;
21
61
  let liveOutputDetailExpanded = false; // Start with summary view, one click for full
22
62
  let liveOutputBuffer = [];
23
63
  const MAX_OUTPUT_LINES = 200;
@@ -25,12 +65,73 @@ const LIVE_EVENT_MAX = 50;
25
65
  let liveOutputEvents = [];
26
66
  let liveOutputEventIndex = {};
27
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
+ }
28
78
  // Dispatch panel collapse state (persisted to localStorage)
29
79
  const DISPATCH_PANEL_COLLAPSED_KEY = "car-dispatch-panel-collapsed";
30
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;
31
84
  // Throttling state
32
85
  let liveOutputRenderPending = false;
33
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
+ });
34
135
  function scheduleLiveOutputRender() {
35
136
  if (liveOutputRenderPending)
36
137
  return;
@@ -194,6 +295,77 @@ function stopLastActivityTimer() {
194
295
  lastActivityTimerId = null;
195
296
  }
196
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
+ }
197
369
  function appendToLiveOutput(text) {
198
370
  if (!text)
199
371
  return;
@@ -418,6 +590,7 @@ function setLiveOutputStatus(status) {
418
590
  function handleFlowEvent(event) {
419
591
  // Update last activity time
420
592
  lastActivityTime = new Date(event.timestamp);
593
+ lastKnownEventAt = lastActivityTime;
421
594
  updateLastActivityDisplay();
422
595
  // Handle agent stream delta events
423
596
  if (event.event_type === "agent_stream_delta") {
@@ -435,6 +608,20 @@ function handleFlowEvent(event) {
435
608
  scheduleLiveOutputRender();
436
609
  }
437
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
+ }
438
625
  // Handle flow lifecycle events
439
626
  if (event.event_type === "flow_completed" ||
440
627
  event.event_type === "flow_failed" ||
@@ -451,20 +638,38 @@ function handleFlowEvent(event) {
451
638
  }
452
639
  }
453
640
  }
454
- function connectEventStream(runId) {
641
+ function connectEventStream(runId, afterSeq) {
455
642
  disconnectEventStream();
643
+ clearEventStreamRetry();
644
+ eventSourceRunId = runId;
456
645
  const token = getAuthToken();
457
- let url = resolvePath(`/api/flows/${runId}/events`);
646
+ const url = new URL(resolvePath(`/api/flows/${runId}/events`), window.location.origin);
458
647
  if (token) {
459
- url += `?token=${encodeURIComponent(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
+ }
460
658
  }
461
- eventSource = new EventSource(url);
659
+ eventSource = new EventSource(url.toString());
462
660
  eventSource.onopen = () => {
463
661
  setLiveOutputStatus("connected");
662
+ eventSourceRetryAttempt = 0;
663
+ clearEventStreamRetry();
464
664
  };
465
665
  eventSource.onmessage = (event) => {
466
666
  try {
467
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
+ }
468
673
  handleFlowEvent(data);
469
674
  }
470
675
  catch (err) {
@@ -473,7 +678,11 @@ function connectEventStream(runId) {
473
678
  };
474
679
  eventSource.onerror = () => {
475
680
  setLiveOutputStatus("disconnected");
476
- // Don't auto-reconnect here - loadTicketFlow will handle it
681
+ if (eventSource) {
682
+ eventSource.close();
683
+ eventSource = null;
684
+ }
685
+ scheduleEventStreamReconnect(runId);
477
686
  };
478
687
  }
479
688
  function disconnectEventStream() {
@@ -481,6 +690,8 @@ function disconnectEventStream() {
481
690
  eventSource.close();
482
691
  eventSource = null;
483
692
  }
693
+ clearEventStreamRetry();
694
+ eventSourceRunId = null;
484
695
  setLiveOutputStatus("disconnected");
485
696
  }
486
697
  function initLiveOutputPanel() {
@@ -537,6 +748,12 @@ function els() {
537
748
  progress: document.getElementById("ticket-flow-progress"),
538
749
  reason: document.getElementById("ticket-flow-reason"),
539
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"),
540
757
  dir: document.getElementById("ticket-flow-dir"),
541
758
  tickets: document.getElementById("ticket-flow-tickets"),
542
759
  history: document.getElementById("ticket-dispatch-history"),
@@ -550,41 +767,106 @@ function els() {
550
767
  stopBtn: document.getElementById("ticket-flow-stop"),
551
768
  restartBtn: document.getElementById("ticket-flow-restart"),
552
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"),
553
775
  };
554
776
  }
555
777
  function setButtonsDisabled(disabled) {
556
- const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
557
- [bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn].forEach((btn) => {
778
+ const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
779
+ [bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn].forEach((btn) => {
558
780
  if (btn)
559
781
  btn.disabled = disabled;
560
782
  });
561
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
+ }
562
827
  function truncate(text, max = 100) {
563
828
  if (text.length <= max)
564
829
  return text;
565
830
  return `${text.slice(0, max).trim()}…`;
566
831
  }
567
832
  function renderTickets(data) {
568
- const { tickets, dir, bootstrapBtn } = els();
833
+ ticketListCache = data;
834
+ const { tickets, dir } = els();
569
835
  if (dir)
570
836
  dir.textContent = data?.ticket_dir || "–";
571
837
  if (!tickets)
572
838
  return;
573
839
  tickets.innerHTML = "";
840
+ // Display lint errors if present
841
+ if (data?.lint_errors && data.lint_errors.length > 0) {
842
+ const lintBanner = document.createElement("div");
843
+ lintBanner.className = "ticket-lint-errors";
844
+ data.lint_errors.forEach((error) => {
845
+ const errorLine = document.createElement("div");
846
+ errorLine.textContent = error;
847
+ lintBanner.appendChild(errorLine);
848
+ });
849
+ tickets.appendChild(lintBanner);
850
+ }
574
851
  const list = (data?.tickets || []);
575
852
  ticketsExist = list.length > 0;
576
- // Disable start button if no tickets exist
577
- if (bootstrapBtn && !bootstrapBtn.disabled) {
578
- bootstrapBtn.disabled = !ticketsExist;
579
- if (!ticketsExist) {
580
- bootstrapBtn.title = "Create a ticket first";
853
+ // Update progress bar
854
+ const progressBar = document.getElementById("ticket-progress-bar");
855
+ const progressFill = document.getElementById("ticket-progress-fill");
856
+ if (progressBar && progressFill) {
857
+ if (list.length === 0) {
858
+ progressBar.classList.add("hidden");
581
859
  }
582
860
  else {
583
- bootstrapBtn.title = "";
861
+ progressBar.classList.remove("hidden");
862
+ const doneCount = list.filter((t) => Boolean((t.frontmatter || {})?.done)).length;
863
+ const percent = Math.round((doneCount / list.length) * 100);
864
+ progressFill.style.width = `${percent}%`;
865
+ progressBar.title = `${doneCount} of ${list.length} tickets done`;
584
866
  }
585
867
  }
586
868
  if (!list.length) {
587
- tickets.textContent = "No tickets found. Create TICKET-001.md to begin.";
869
+ tickets.textContent = "No tickets found. Start the ticket flow to create TICKET-001.md.";
588
870
  return;
589
871
  }
590
872
  list.forEach((ticket) => {
@@ -592,12 +874,26 @@ function renderTickets(data) {
592
874
  const fm = (ticket.frontmatter || {});
593
875
  const done = Boolean(fm?.done);
594
876
  // Check if this ticket is currently being worked on
595
- const isActive = currentActiveTicket && ticket.path === currentActiveTicket && currentFlowStatus === "running";
596
- item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} clickable`;
877
+ const isActive = Boolean(currentActiveTicket &&
878
+ ticket.path === currentActiveTicket &&
879
+ isFlowActiveStatus(currentFlowStatus));
880
+ item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} ${selectedTicketPath === ticket.path ? "selected" : ""} clickable`;
597
881
  item.title = "Click to edit";
882
+ item.setAttribute("data-ticket-path", ticket.path || "");
598
883
  // Make ticket item clickable to open editor
599
- item.addEventListener("click", () => {
600
- openTicketEditor(ticket);
884
+ item.addEventListener("click", async () => {
885
+ updateSelectedTicket(ticket.path || null);
886
+ try {
887
+ if (ticket.index == null) {
888
+ flash("Invalid ticket: missing index", "error");
889
+ return;
890
+ }
891
+ const data = (await api(`/api/flows/ticket_flow/tickets/${ticket.index}`));
892
+ openTicketEditor(data);
893
+ }
894
+ catch (err) {
895
+ flash(`Failed to load ticket: ${err.message}`, "error");
896
+ }
601
897
  });
602
898
  const head = document.createElement("div");
603
899
  head.className = "ticket-item-head";
@@ -631,20 +927,39 @@ function renderTickets(data) {
631
927
  if (isActive) {
632
928
  const workingBadge = document.createElement("span");
633
929
  workingBadge.className = "ticket-working-badge";
634
- workingBadge.textContent = "Working";
930
+ // Text content used on middle responsive view; CSS hides text on desktop/mobile
931
+ const workingText = document.createElement("span");
932
+ workingText.className = "badge-text";
933
+ workingText.textContent = "Working";
934
+ workingBadge.appendChild(workingText);
635
935
  badges.appendChild(workingBadge);
636
936
  }
637
937
  // Add DONE badge for completed tickets
638
938
  if (done && !isActive) {
639
939
  const doneBadge = document.createElement("span");
640
940
  doneBadge.className = "ticket-done-badge";
641
- doneBadge.textContent = "Done";
941
+ // Text content used on middle responsive view; CSS hides text on desktop/mobile
942
+ const doneText = document.createElement("span");
943
+ doneText.className = "badge-text";
944
+ doneText.textContent = "Done";
945
+ doneBadge.appendChild(doneText);
642
946
  badges.appendChild(doneBadge);
643
947
  }
644
948
  const agent = document.createElement("span");
645
949
  agent.className = "ticket-agent";
646
950
  agent.textContent = fm?.agent || "codex";
647
951
  badges.appendChild(agent);
952
+ // Cumulative diff stats (from FlowStore DIFF_UPDATED aggregation).
953
+ const diffStats = ticket.diff_stats || null;
954
+ if (diffStats && (diffStats.insertions > 0 || diffStats.deletions > 0)) {
955
+ const statsEl = document.createElement("span");
956
+ statsEl.className = "ticket-diff-stats";
957
+ const ins = diffStats.insertions || 0;
958
+ const del = diffStats.deletions || 0;
959
+ statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
960
+ statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
961
+ badges.appendChild(statsEl);
962
+ }
648
963
  head.appendChild(badges);
649
964
  item.appendChild(head);
650
965
  if (ticket.errors && ticket.errors.length) {
@@ -661,6 +976,8 @@ function renderTickets(data) {
661
976
  }
662
977
  tickets.appendChild(item);
663
978
  });
979
+ // Update scroll fade indicator after rendering
980
+ updateScrollFade();
664
981
  }
665
982
  function renderDispatchHistory(runId, data) {
666
983
  const { history, dispatchNote } = els();
@@ -689,26 +1006,58 @@ function renderDispatchHistory(runId, data) {
689
1006
  dispatchNote.textContent = `Latest #${entries[0]?.seq ?? "–"}`;
690
1007
  // Also render mini list for collapsed panel view
691
1008
  renderDispatchMiniList(entries);
692
- entries.forEach((entry) => {
1009
+ entries.forEach((entry, index) => {
693
1010
  const dispatch = entry.dispatch;
694
1011
  const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
695
1012
  const isHandoff = dispatch?.mode === "pause";
1013
+ const isNotify = dispatch?.mode === "notify";
1014
+ // Expand only the first (newest) dispatch by default - entries are newest-first
1015
+ const isFirst = index === 0;
1016
+ const isCollapsed = !isFirst;
696
1017
  const container = document.createElement("div");
697
- container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""} clickable`;
698
- container.title = isTurnSummary ? "Agent turn output" : "Click to view in Inbox";
699
- // Add click handler to navigate to inbox (skip for turn summaries)
700
- if (!isTurnSummary) {
701
- container.addEventListener("click", () => {
702
- if (runId) {
703
- // Update URL with run_id so inbox tab loads the right thread
704
- const url = new URL(window.location.href);
705
- url.searchParams.set("run_id", runId);
706
- window.history.replaceState({}, "", url.toString());
707
- // Switch to inbox tab
708
- activateTab("inbox");
709
- }
710
- });
711
- }
1018
+ container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""}${isHandoff ? " pause" : ""}${isNotify ? " notify" : ""}${isCollapsed ? " collapsed" : ""}`;
1019
+ // Reddit-style thin collapse bar on the left
1020
+ const collapseBar = document.createElement("div");
1021
+ collapseBar.className = "dispatch-collapse-bar";
1022
+ collapseBar.title = isCollapsed ? "Click to expand" : "Click to collapse";
1023
+ collapseBar.setAttribute("role", "button");
1024
+ collapseBar.setAttribute("tabindex", "0");
1025
+ collapseBar.setAttribute("aria-label", isCollapsed ? "Expand dispatch" : "Collapse dispatch");
1026
+ collapseBar.setAttribute("aria-expanded", String(!isCollapsed));
1027
+ const toggleCollapse = () => {
1028
+ container.classList.toggle("collapsed");
1029
+ const isNowCollapsed = container.classList.contains("collapsed");
1030
+ collapseBar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
1031
+ collapseBar.setAttribute("aria-expanded", String(!isNowCollapsed));
1032
+ collapseBar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
1033
+ };
1034
+ collapseBar.addEventListener("click", (e) => {
1035
+ e.stopPropagation();
1036
+ toggleCollapse();
1037
+ });
1038
+ collapseBar.addEventListener("keydown", (e) => {
1039
+ if (e.key === "Enter" || e.key === " ") {
1040
+ e.preventDefault();
1041
+ toggleCollapse();
1042
+ }
1043
+ });
1044
+ // Content wrapper for header and body
1045
+ const contentWrapper = document.createElement("div");
1046
+ contentWrapper.className = "dispatch-content-wrapper";
1047
+ // Create collapsible structure
1048
+ const header = document.createElement("div");
1049
+ header.className = "dispatch-header";
1050
+ // Make header clickable to toggle collapse
1051
+ header.addEventListener("click", (e) => {
1052
+ // Don't toggle if clicking on a link or navigating to inbox
1053
+ if (e.target.closest("a"))
1054
+ return;
1055
+ toggleCollapse();
1056
+ });
1057
+ // Header content area
1058
+ const headerContent = document.createElement("div");
1059
+ headerContent.className = "dispatch-header-content";
1060
+ headerContent.title = isTurnSummary ? "Agent turn output" : "Click header to expand/collapse";
712
1061
  // Determine mode label
713
1062
  let modeLabel;
714
1063
  if (isTurnSummary) {
@@ -729,6 +1078,24 @@ function renderDispatchHistory(runId, data) {
729
1078
  mode.className = `ticket-agent${isTurnSummary ? " turn-summary-badge" : ""}`;
730
1079
  mode.textContent = modeLabel;
731
1080
  head.append(seq, mode);
1081
+ headerContent.appendChild(head);
1082
+ header.appendChild(headerContent);
1083
+ contentWrapper.appendChild(header);
1084
+ container.append(collapseBar, contentWrapper);
1085
+ // Add diff stats if present (for turn summaries)
1086
+ // New path: dispatch.diff_stats (from FlowStore DIFF_UPDATED merge)
1087
+ // Legacy fallback: dispatch.extra.diff_stats (DISPATCH.md frontmatter)
1088
+ const diffStats = (dispatch?.diff_stats ||
1089
+ dispatch?.extra?.diff_stats);
1090
+ if (diffStats && (diffStats.insertions || diffStats.deletions)) {
1091
+ const statsEl = document.createElement("span");
1092
+ statsEl.className = "dispatch-diff-stats";
1093
+ const ins = diffStats.insertions || 0;
1094
+ const del = diffStats.deletions || 0;
1095
+ statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
1096
+ statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
1097
+ head.appendChild(statsEl);
1098
+ }
732
1099
  // Add ticket reference if present
733
1100
  const ticketId = dispatch?.extra?.ticket_id;
734
1101
  if (ticketId) {
@@ -742,26 +1109,36 @@ function renderDispatchHistory(runId, data) {
742
1109
  head.appendChild(ticketLabel);
743
1110
  }
744
1111
  }
745
- container.appendChild(head);
1112
+ // Add timestamp
1113
+ const timeAgo = formatDispatchTime(entry.created_at);
1114
+ if (timeAgo) {
1115
+ const timeLabel = document.createElement("span");
1116
+ timeLabel.className = "dispatch-time";
1117
+ timeLabel.textContent = timeAgo;
1118
+ head.appendChild(timeLabel);
1119
+ }
1120
+ // Create collapsible body content
1121
+ const bodyWrapper = document.createElement("div");
1122
+ bodyWrapper.className = "dispatch-body-wrapper";
746
1123
  if (entry.errors && entry.errors.length) {
747
1124
  const err = document.createElement("div");
748
1125
  err.className = "ticket-errors";
749
1126
  err.textContent = entry.errors.join("; ");
750
- container.appendChild(err);
1127
+ bodyWrapper.appendChild(err);
751
1128
  }
752
1129
  const title = dispatch?.title;
753
1130
  if (title) {
754
1131
  const titleEl = document.createElement("div");
755
1132
  titleEl.className = "ticket-body ticket-dispatch-title";
756
1133
  titleEl.textContent = title;
757
- container.appendChild(titleEl);
1134
+ bodyWrapper.appendChild(titleEl);
758
1135
  }
759
1136
  const bodyText = dispatch?.body;
760
1137
  if (bodyText) {
761
1138
  const body = document.createElement("div");
762
1139
  body.className = "ticket-body ticket-dispatch-body messages-markdown";
763
1140
  body.innerHTML = renderMarkdown(bodyText);
764
- container.appendChild(body);
1141
+ bodyWrapper.appendChild(body);
765
1142
  }
766
1143
  const attachments = (entry.attachments || []);
767
1144
  if (attachments.length) {
@@ -771,17 +1148,28 @@ function renderDispatchHistory(runId, data) {
771
1148
  if (!att.url)
772
1149
  return;
773
1150
  const link = document.createElement("a");
774
- link.href = resolvePath(att.url);
1151
+ const resolved = new URL(resolvePath(att.url), window.location.origin);
1152
+ link.href = resolved.toString();
775
1153
  link.textContent = att.name || att.rel_path || "attachment";
776
- link.target = "_blank";
777
- link.rel = "noreferrer noopener";
1154
+ // Prefer direct downloads for same-origin attachments.
1155
+ if (resolved.origin === window.location.origin) {
1156
+ link.download = "";
1157
+ link.rel = "noopener";
1158
+ }
1159
+ else {
1160
+ link.target = "_blank";
1161
+ link.rel = "noreferrer noopener";
1162
+ }
778
1163
  link.title = att.path || "";
779
1164
  wrap.appendChild(link);
780
1165
  });
781
- container.appendChild(wrap);
1166
+ bodyWrapper.appendChild(wrap);
782
1167
  }
1168
+ contentWrapper.appendChild(bodyWrapper);
783
1169
  history.appendChild(container);
784
1170
  });
1171
+ // Update scroll fade indicator after rendering
1172
+ updateScrollFade();
785
1173
  }
786
1174
  const MAX_REASON_LENGTH = 60;
787
1175
  /**
@@ -814,7 +1202,10 @@ function summarizeReason(run) {
814
1202
  const engine = (state.ticket_engine || {});
815
1203
  const fullReason = getFullReason(run);
816
1204
  currentReasonFull = fullReason;
817
- const shortReason = engine.reason ||
1205
+ const reasonSummary = typeof run.reason_summary === "string" ? run.reason_summary : "";
1206
+ const useSummary = run.status === "paused" || run.status === "failed" || run.status === "stopped";
1207
+ const shortReason = (useSummary && reasonSummary ? reasonSummary : "") ||
1208
+ engine.reason ||
818
1209
  run.error_message ||
819
1210
  (engine.current_ticket ? `Working on ${engine.current_ticket}` : "") ||
820
1211
  run.status ||
@@ -825,16 +1216,30 @@ function summarizeReason(run) {
825
1216
  }
826
1217
  return shortReason;
827
1218
  }
828
- async function loadTicketFiles() {
1219
+ async function loadTicketFiles(ctx) {
829
1220
  const { tickets } = els();
830
- if (tickets)
1221
+ const isInitial = ticketListRefresh.getSignature() === null;
1222
+ if (tickets && isInitial) {
831
1223
  tickets.textContent = "Loading tickets…";
1224
+ }
832
1225
  try {
833
- const data = (await api("/api/flows/ticket_flow/tickets"));
834
- renderTickets(data);
1226
+ await ticketListRefresh.refresh(async () => {
1227
+ const data = (await api("/api/flows/ticket_flow/tickets"));
1228
+ return {
1229
+ ticket_dir: data.ticket_dir,
1230
+ tickets: data.tickets,
1231
+ lint_errors: data.lint_errors,
1232
+ activeTicket: currentActiveTicket,
1233
+ flowStatus: currentFlowStatus,
1234
+ };
1235
+ }, { reason: ctx?.reason === "manual" ? "manual" : "background" });
835
1236
  }
836
1237
  catch (err) {
837
- renderTickets(null);
1238
+ ticketListRefresh.reset();
1239
+ ticketListCache = null;
1240
+ preserveScroll(tickets, () => {
1241
+ renderTickets(null);
1242
+ }, { restoreOnNextFrame: true });
838
1243
  flash(err.message || "Failed to load tickets", "error");
839
1244
  }
840
1245
  }
@@ -843,10 +1248,9 @@ async function loadTicketFiles() {
843
1248
  */
844
1249
  async function openTicketByIndex(index) {
845
1250
  try {
846
- const data = (await api("/api/flows/ticket_flow/tickets"));
847
- const ticket = data.tickets?.find((t) => t.index === index);
848
- if (ticket) {
849
- openTicketEditor(ticket);
1251
+ const data = (await api(`/api/flows/ticket_flow/tickets/${index}`));
1252
+ if (data) {
1253
+ openTicketEditor(data);
850
1254
  }
851
1255
  else {
852
1256
  flash(`Ticket TICKET-${String(index).padStart(3, "0")} not found`, "error");
@@ -856,26 +1260,45 @@ async function openTicketByIndex(index) {
856
1260
  flash(`Failed to open ticket: ${err.message}`, "error");
857
1261
  }
858
1262
  }
859
- async function loadDispatchHistory(runId) {
1263
+ async function loadDispatchHistory(runId, ctx) {
860
1264
  const { history } = els();
861
- if (history)
862
- history.textContent = "Loading dispatch history…";
1265
+ const runChanged = dispatchHistoryRunId !== runId;
863
1266
  if (!runId) {
864
1267
  renderDispatchHistory(null, null);
1268
+ dispatchHistoryRefresh.reset();
1269
+ dispatchHistoryRunId = null;
865
1270
  return;
866
1271
  }
1272
+ if (runChanged) {
1273
+ dispatchHistoryRunId = runId;
1274
+ dispatchHistoryRefresh.reset();
1275
+ }
1276
+ const isInitial = dispatchHistoryRefresh.getSignature() === null;
1277
+ if (history && isInitial) {
1278
+ history.textContent = "Loading dispatch history…";
1279
+ }
867
1280
  try {
868
- // Use dispatch_history endpoint
869
- const data = (await api(`/api/flows/${runId}/dispatch_history`));
870
- renderDispatchHistory(runId, data);
1281
+ await dispatchHistoryRefresh.refresh(async () => {
1282
+ const data = (await api(`/api/flows/${runId}/dispatch_history`));
1283
+ return {
1284
+ runId,
1285
+ history: data.history,
1286
+ };
1287
+ }, {
1288
+ reason: ctx?.reason === "manual" ? "manual" : "background",
1289
+ force: runChanged,
1290
+ });
871
1291
  }
872
1292
  catch (err) {
873
- renderDispatchHistory(runId, null);
1293
+ dispatchHistoryRefresh.reset();
1294
+ preserveScroll(history, () => {
1295
+ renderDispatchHistory(runId, null);
1296
+ }, { restoreOnNextFrame: true });
874
1297
  flash(err.message || "Failed to load dispatch history", "error");
875
1298
  }
876
1299
  }
877
- async function loadTicketFlow() {
878
- const { status, run, current, turn, elapsed, progress, reason, lastActivity, resumeBtn, bootstrapBtn, stopBtn, archiveBtn } = els();
1300
+ async function loadTicketFlow(ctx) {
1301
+ const { status, run, current, turn, elapsed, progress, reason, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, resumeBtn, bootstrapBtn, stopBtn, archiveBtn, refreshBtn, } = els();
879
1302
  if (!isRepoHealthy()) {
880
1303
  if (status)
881
1304
  statusPill(status, "error");
@@ -891,14 +1314,29 @@ async function loadTicketFlow() {
891
1314
  progress.textContent = "–";
892
1315
  if (lastActivity)
893
1316
  lastActivity.textContent = "–";
1317
+ if (stalePill)
1318
+ stalePill.style.display = "none";
1319
+ if (reconnectBtn)
1320
+ reconnectBtn.style.display = "none";
1321
+ if (workerStatus)
1322
+ workerStatus.textContent = "–";
1323
+ if (workerPill)
1324
+ workerPill.style.display = "none";
1325
+ if (recoverBtn)
1326
+ recoverBtn.style.display = "none";
894
1327
  if (reason)
895
1328
  reason.textContent = "Repo offline or uninitialized.";
896
1329
  setButtonsDisabled(true);
1330
+ setButtonLoading(refreshBtn, false);
897
1331
  stopElapsedTimer();
898
1332
  stopLastActivityTimer();
899
1333
  disconnectEventStream();
900
1334
  return;
901
1335
  }
1336
+ const showRefreshIndicator = ticketFlowLoaded;
1337
+ if (showRefreshIndicator) {
1338
+ setButtonLoading(refreshBtn, true);
1339
+ }
902
1340
  try {
903
1341
  const runs = (await api("/api/flows/runs?flow_type=ticket_flow"));
904
1342
  // Only consider the newest run - if it's terminal, flow is idle.
@@ -911,7 +1349,10 @@ async function loadTicketFlow() {
911
1349
  currentFlowStatus = latest?.status || null;
912
1350
  // Extract ticket engine state
913
1351
  const ticketEngine = latest?.state?.ticket_engine;
914
- currentActiveTicket = ticketEngine?.current_ticket || null;
1352
+ // The server now provides an effective current_ticket during in-flight steps.
1353
+ // Trust the API value even when null so we don't show stale DONE+WORKING between steps.
1354
+ const apiActiveTicket = ticketEngine?.current_ticket || null;
1355
+ currentActiveTicket = apiActiveTicket;
915
1356
  const ticketTurns = ticketEngine?.ticket_turns ?? null;
916
1357
  const totalTurns = ticketEngine?.total_turns ?? null;
917
1358
  if (status)
@@ -922,7 +1363,7 @@ async function loadTicketFlow() {
922
1363
  current.textContent = currentActiveTicket || "–";
923
1364
  // Display turn counter
924
1365
  if (turn) {
925
- if (ticketTurns !== null && currentFlowStatus === "running") {
1366
+ if (ticketTurns !== null && isFlowActiveStatus(currentFlowStatus)) {
926
1367
  turn.textContent = `${ticketTurns}${totalTurns !== null ? ` (${totalTurns} total)` : ""}`;
927
1368
  }
928
1369
  else {
@@ -949,6 +1390,37 @@ async function loadTicketFlow() {
949
1390
  (currentReasonFull && currentReasonFull.length > MAX_REASON_LENGTH));
950
1391
  reason.classList.toggle("has-details", hasDetails);
951
1392
  }
1393
+ lastKnownEventSeq = typeof latest?.last_event_seq === "number" ? latest.last_event_seq : null;
1394
+ if (currentRunId && typeof lastKnownEventSeq === "number") {
1395
+ setLastSeenSeq(currentRunId, lastKnownEventSeq);
1396
+ }
1397
+ updateLastActivityFromTimestamp(latest?.last_event_at || null);
1398
+ const isActive = latest?.status === "running" || latest?.status === "pending";
1399
+ const isStale = Boolean(isActive &&
1400
+ lastKnownEventAt &&
1401
+ Date.now() - lastKnownEventAt.getTime() > STALE_THRESHOLD_MS);
1402
+ if (stalePill)
1403
+ stalePill.style.display = isStale ? "" : "none";
1404
+ if (reconnectBtn) {
1405
+ reconnectBtn.style.display = isStale ? "" : "none";
1406
+ reconnectBtn.disabled = !currentRunId;
1407
+ }
1408
+ const worker = latest?.worker_health;
1409
+ const workerLabel = worker?.status
1410
+ ? `${worker.status}${worker.pid ? ` (pid ${worker.pid})` : ""}`
1411
+ : "–";
1412
+ if (workerStatus)
1413
+ workerStatus.textContent = workerLabel;
1414
+ const workerDead = Boolean(isActive &&
1415
+ worker &&
1416
+ worker.is_alive === false &&
1417
+ worker.status !== "absent");
1418
+ if (workerPill)
1419
+ workerPill.style.display = workerDead ? "" : "none";
1420
+ if (recoverBtn) {
1421
+ recoverBtn.style.display = workerDead ? "" : "none";
1422
+ recoverBtn.disabled = !currentRunId;
1423
+ }
952
1424
  if (resumeBtn) {
953
1425
  resumeBtn.disabled = !latest?.id || latest.status !== "paused";
954
1426
  }
@@ -956,7 +1428,7 @@ async function loadTicketFlow() {
956
1428
  const stoppable = latest?.status === "running" || latest?.status === "pending";
957
1429
  stopBtn.disabled = !latest?.id || !stoppable;
958
1430
  }
959
- await loadTicketFiles();
1431
+ await loadTicketFiles(ctx);
960
1432
  // Calculate and display ticket progress (scoped to tickets container only)
961
1433
  if (progress) {
962
1434
  const ticketsContainer = document.getElementById("ticket-flow-tickets");
@@ -972,41 +1444,45 @@ async function loadTicketFlow() {
972
1444
  // Connect/disconnect event stream based on flow status
973
1445
  if (currentRunId && (latest?.status === "running" || latest?.status === "pending")) {
974
1446
  // Only connect if not already connected to this run
975
- if (!eventSource || eventSource.url?.indexOf(currentRunId) === -1) {
1447
+ const isSameRun = eventSourceRunId === currentRunId;
1448
+ const isClosed = eventSource?.readyState === EventSource.CLOSED;
1449
+ if (!eventSource || !isSameRun || isClosed) {
976
1450
  connectEventStream(currentRunId);
977
1451
  startLastActivityTimer();
978
1452
  }
979
1453
  }
980
1454
  else {
981
1455
  disconnectEventStream();
982
- stopLastActivityTimer();
983
- if (lastActivity)
984
- lastActivity.textContent = "–";
985
- lastActivityTime = null;
1456
+ if (!lastKnownEventAt) {
1457
+ stopLastActivityTimer();
1458
+ if (lastActivity)
1459
+ lastActivity.textContent = "–";
1460
+ lastActivityTime = null;
1461
+ }
986
1462
  }
987
1463
  if (bootstrapBtn) {
988
1464
  const busy = latest?.status === "running" || latest?.status === "pending";
989
- // Disable if busy OR if no tickets exist
990
- bootstrapBtn.disabled = busy || !ticketsExist;
1465
+ // Disable only if busy; bootstrap will create initial ticket when missing
1466
+ bootstrapBtn.disabled = busy;
991
1467
  bootstrapBtn.textContent = busy ? "Running…" : "Start Ticket Flow";
992
- if (!ticketsExist && !busy) {
993
- bootstrapBtn.title = "Create a ticket first";
994
- }
995
- else {
996
- bootstrapBtn.title = "";
997
- }
1468
+ bootstrapBtn.title = busy ? "Ticket flow in progress" : "";
998
1469
  }
999
1470
  // Show restart button when flow is paused, stopping, or in terminal state (allows starting fresh)
1000
- const { restartBtn } = els();
1471
+ const { restartBtn, overflowRestart } = els();
1001
1472
  if (restartBtn) {
1002
1473
  const isPaused = latest?.status === "paused";
1003
1474
  const isStopping = latest?.status === "stopping";
1004
1475
  const isTerminal = latest?.status === "completed" ||
1005
1476
  latest?.status === "stopped" ||
1006
1477
  latest?.status === "failed";
1007
- const canRestart = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
1478
+ const canRestart = (isPaused || isStopping || isTerminal || workerDead) &&
1479
+ ticketsExist &&
1480
+ Boolean(currentRunId);
1008
1481
  restartBtn.style.display = canRestart ? "" : "none";
1009
1482
  restartBtn.disabled = !canRestart;
1483
+ if (overflowRestart) {
1484
+ overflowRestart.style.display = canRestart ? "" : "none";
1485
+ }
1010
1486
  }
1011
1487
  // Show archive button when flow is paused, stopping, or in terminal state and has tickets
1012
1488
  if (archiveBtn) {
@@ -1018,14 +1494,24 @@ async function loadTicketFlow() {
1018
1494
  const canArchive = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
1019
1495
  archiveBtn.style.display = canArchive ? "" : "none";
1020
1496
  archiveBtn.disabled = !canArchive;
1497
+ const { overflowArchive } = els();
1498
+ if (overflowArchive) {
1499
+ overflowArchive.style.display = canArchive ? "" : "none";
1500
+ }
1021
1501
  }
1022
- await loadDispatchHistory(currentRunId);
1502
+ await loadDispatchHistory(currentRunId, ctx);
1023
1503
  }
1024
1504
  catch (err) {
1025
1505
  if (reason)
1026
1506
  reason.textContent = err.message || "Ticket flow unavailable";
1027
1507
  flash(err.message || "Failed to load ticket flow state", "error");
1028
1508
  }
1509
+ finally {
1510
+ ticketFlowLoaded = true;
1511
+ if (showRefreshIndicator) {
1512
+ setButtonLoading(refreshBtn, false);
1513
+ }
1514
+ }
1029
1515
  }
1030
1516
  async function bootstrapTicketFlow() {
1031
1517
  const { bootstrapBtn } = els();
@@ -1035,13 +1521,9 @@ async function bootstrapTicketFlow() {
1035
1521
  flash("Repo offline; cannot start ticket flow.", "error");
1036
1522
  return;
1037
1523
  }
1038
- if (!ticketsExist) {
1039
- flash("Create a ticket first before starting the flow.", "error");
1040
- return;
1041
- }
1042
1524
  setButtonsDisabled(true);
1043
- bootstrapBtn.textContent = "Starting…";
1044
- try {
1525
+ bootstrapBtn.textContent = "Checking…";
1526
+ const startFlow = async () => {
1045
1527
  const res = (await api("/api/flows/ticket_flow/bootstrap", {
1046
1528
  method: "POST",
1047
1529
  body: {},
@@ -1055,6 +1537,113 @@ async function bootstrapTicketFlow() {
1055
1537
  clearLiveOutput(); // Clear output for new run
1056
1538
  }
1057
1539
  await loadTicketFlow();
1540
+ };
1541
+ const seedIssueFromGithub = async (issueRef) => {
1542
+ await api("/api/flows/ticket_flow/seed-issue", {
1543
+ method: "POST",
1544
+ body: { issue_ref: issueRef },
1545
+ });
1546
+ flash("ISSUE.md created from GitHub", "success");
1547
+ };
1548
+ const seedIssueFromPlan = async (planText) => {
1549
+ await api("/api/flows/ticket_flow/seed-issue", {
1550
+ method: "POST",
1551
+ body: { plan_text: planText },
1552
+ });
1553
+ flash("ISSUE.md created from your input", "success");
1554
+ };
1555
+ const promptIssueRef = async (repo) => {
1556
+ const message = repo
1557
+ ? `Enter GitHub issue number or URL for ${repo}`
1558
+ : "Enter GitHub issue number or URL";
1559
+ const input = await inputModal(message, {
1560
+ placeholder: "#123 or https://github.com/org/repo/issues/123",
1561
+ confirmText: "Fetch issue",
1562
+ });
1563
+ const value = (input || "").trim();
1564
+ return value || null;
1565
+ };
1566
+ const promptPlanText = async () => {
1567
+ // Build a simple textarea modal dynamically to avoid new HTML templates.
1568
+ const overlay = document.createElement("div");
1569
+ overlay.className = "modal-overlay";
1570
+ overlay.hidden = true;
1571
+ const dialog = document.createElement("div");
1572
+ dialog.className = "modal-dialog";
1573
+ dialog.setAttribute("role", "dialog");
1574
+ dialog.setAttribute("aria-modal", "true");
1575
+ dialog.tabIndex = -1;
1576
+ const title = document.createElement("h3");
1577
+ title.textContent = "Describe the work";
1578
+ const textarea = document.createElement("textarea");
1579
+ textarea.placeholder = "Describe the scope/requirements to seed ISSUE.md";
1580
+ textarea.rows = 6;
1581
+ textarea.style.width = "100%";
1582
+ textarea.style.resize = "vertical";
1583
+ const actions = document.createElement("div");
1584
+ actions.className = "modal-actions";
1585
+ const cancel = document.createElement("button");
1586
+ cancel.className = "ghost";
1587
+ cancel.textContent = "Cancel";
1588
+ const submit = document.createElement("button");
1589
+ submit.className = "primary";
1590
+ submit.textContent = "Create ISSUE.md";
1591
+ actions.append(cancel, submit);
1592
+ dialog.append(title, textarea, actions);
1593
+ overlay.append(dialog);
1594
+ document.body.append(overlay);
1595
+ return await new Promise((resolve) => {
1596
+ let closeModal = null;
1597
+ const cleanup = () => {
1598
+ if (closeModal)
1599
+ closeModal();
1600
+ overlay.remove();
1601
+ };
1602
+ const finalize = (value) => {
1603
+ cleanup();
1604
+ resolve(value);
1605
+ };
1606
+ closeModal = openModal(overlay, {
1607
+ initialFocus: textarea,
1608
+ returnFocusTo: bootstrapBtn,
1609
+ onRequestClose: () => finalize(null),
1610
+ });
1611
+ submit.addEventListener("click", () => {
1612
+ finalize(textarea.value.trim() || null);
1613
+ });
1614
+ cancel.addEventListener("click", () => finalize(null));
1615
+ });
1616
+ };
1617
+ try {
1618
+ const check = (await api("/api/flows/ticket_flow/bootstrap-check", {
1619
+ method: "GET",
1620
+ }));
1621
+ if (check.status === "ready") {
1622
+ await startFlow();
1623
+ return;
1624
+ }
1625
+ if (check.status === "needs_issue") {
1626
+ if (check.github_available) {
1627
+ const issueRef = await promptIssueRef(check.repo);
1628
+ if (!issueRef) {
1629
+ flash("Bootstrap cancelled (no issue provided)", "info");
1630
+ return;
1631
+ }
1632
+ await seedIssueFromGithub(issueRef);
1633
+ }
1634
+ else {
1635
+ const planText = await promptPlanText();
1636
+ if (!planText) {
1637
+ flash("Bootstrap cancelled (no description provided)", "info");
1638
+ return;
1639
+ }
1640
+ await seedIssueFromPlan(planText);
1641
+ }
1642
+ await startFlow();
1643
+ return;
1644
+ }
1645
+ // Fallback: start normally
1646
+ await startFlow();
1058
1647
  }
1059
1648
  catch (err) {
1060
1649
  flash(err.message || "Failed to start ticket flow", "error");
@@ -1091,6 +1680,17 @@ async function resumeTicketFlow() {
1091
1680
  setButtonsDisabled(false);
1092
1681
  }
1093
1682
  }
1683
+ function reconnectTicketFlowStream() {
1684
+ if (!currentRunId) {
1685
+ flash("No ticket flow run to reconnect", "info");
1686
+ return;
1687
+ }
1688
+ const afterSeq = typeof lastKnownEventSeq === "number"
1689
+ ? lastKnownEventSeq
1690
+ : getLastSeenSeq(currentRunId);
1691
+ connectEventStream(currentRunId, afterSeq ?? undefined);
1692
+ flash("Reconnecting event stream", "info");
1693
+ }
1094
1694
  async function stopTicketFlow() {
1095
1695
  const { stopBtn } = els();
1096
1696
  if (!stopBtn)
@@ -1118,6 +1718,33 @@ async function stopTicketFlow() {
1118
1718
  setButtonsDisabled(false);
1119
1719
  }
1120
1720
  }
1721
+ async function recoverTicketFlow() {
1722
+ const { recoverBtn } = els();
1723
+ if (!recoverBtn)
1724
+ return;
1725
+ if (!isRepoHealthy()) {
1726
+ flash("Repo offline; cannot recover ticket flow.", "error");
1727
+ return;
1728
+ }
1729
+ if (!currentRunId) {
1730
+ flash("No ticket flow run to recover", "info");
1731
+ return;
1732
+ }
1733
+ setButtonsDisabled(true);
1734
+ recoverBtn.textContent = "Recovering…";
1735
+ try {
1736
+ await api(`/api/flows/${currentRunId}/reconcile`, { method: "POST", body: {} });
1737
+ flash("Flow reconciled");
1738
+ await loadTicketFlow();
1739
+ }
1740
+ catch (err) {
1741
+ flash(err.message || "Failed to recover ticket flow", "error");
1742
+ }
1743
+ finally {
1744
+ recoverBtn.textContent = "Recover";
1745
+ setButtonsDisabled(false);
1746
+ }
1747
+ }
1121
1748
  async function restartTicketFlow() {
1122
1749
  const { restartBtn } = els();
1123
1750
  if (!restartBtn)
@@ -1130,7 +1757,8 @@ async function restartTicketFlow() {
1130
1757
  flash("Create a ticket first before restarting the flow.", "error");
1131
1758
  return;
1132
1759
  }
1133
- if (!confirm("Restart ticket flow? This will stop the current run and start a new one.")) {
1760
+ const confirmed = await confirmModal("Restart ticket flow? This will stop the current run and start a new one.");
1761
+ if (!confirmed) {
1134
1762
  return;
1135
1763
  }
1136
1764
  setButtonsDisabled(true);
@@ -1170,7 +1798,8 @@ async function archiveTicketFlow() {
1170
1798
  flash("No ticket flow run to archive", "info");
1171
1799
  return;
1172
1800
  }
1173
- if (!confirm("Archive all tickets from this flow? They will be moved to the run's artifact directory.")) {
1801
+ const confirmed = await confirmModal("Archive all tickets from this flow? They will be moved to the run's artifact directory.");
1802
+ if (!confirmed) {
1174
1803
  return;
1175
1804
  }
1176
1805
  setButtonsDisabled(true);
@@ -1191,7 +1820,7 @@ async function archiveTicketFlow() {
1191
1820
  currentActiveTicket = null;
1192
1821
  currentReasonFull = null;
1193
1822
  // Reset all UI elements to idle state directly (avoid re-fetching stale data)
1194
- const { status, run, current, turn, elapsed, progress, lastActivity, bootstrapBtn, resumeBtn, stopBtn, restartBtn } = els();
1823
+ const { status, run, current, turn, elapsed, progress, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, bootstrapBtn, resumeBtn, stopBtn, restartBtn, archiveBtn } = els();
1195
1824
  if (status)
1196
1825
  statusPill(status, "idle");
1197
1826
  if (run)
@@ -1206,6 +1835,16 @@ async function archiveTicketFlow() {
1206
1835
  progress.textContent = "–";
1207
1836
  if (lastActivity)
1208
1837
  lastActivity.textContent = "–";
1838
+ if (stalePill)
1839
+ stalePill.style.display = "none";
1840
+ if (reconnectBtn)
1841
+ reconnectBtn.style.display = "none";
1842
+ if (workerStatus)
1843
+ workerStatus.textContent = "–";
1844
+ if (workerPill)
1845
+ workerPill.style.display = "none";
1846
+ if (recoverBtn)
1847
+ recoverBtn.style.display = "none";
1209
1848
  if (reason) {
1210
1849
  reason.textContent = "No ticket flow run yet.";
1211
1850
  reason.classList.remove("has-details");
@@ -1228,8 +1867,13 @@ async function archiveTicketFlow() {
1228
1867
  stopBtn.disabled = true;
1229
1868
  if (restartBtn)
1230
1869
  restartBtn.style.display = "none";
1870
+ const { overflowRestart, overflowArchive } = els();
1871
+ if (overflowRestart)
1872
+ overflowRestart.style.display = "none";
1231
1873
  if (archiveBtn)
1232
1874
  archiveBtn.style.display = "none";
1875
+ if (overflowArchive)
1876
+ overflowArchive.style.display = "none";
1233
1877
  // Refresh inbox badge and ticket list (tickets were archived/moved)
1234
1878
  void refreshBell();
1235
1879
  await loadTicketFiles();
@@ -1245,7 +1889,7 @@ async function archiveTicketFlow() {
1245
1889
  }
1246
1890
  }
1247
1891
  export function initTicketFlow() {
1248
- const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
1892
+ const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
1249
1893
  if (!card || card.dataset.ticketInitialized === "1")
1250
1894
  return;
1251
1895
  card.dataset.ticketInitialized = "1";
@@ -1259,14 +1903,73 @@ export function initTicketFlow() {
1259
1903
  restartBtn.addEventListener("click", restartTicketFlow);
1260
1904
  if (archiveBtn)
1261
1905
  archiveBtn.addEventListener("click", archiveTicketFlow);
1906
+ if (reconnectBtn)
1907
+ reconnectBtn.addEventListener("click", reconnectTicketFlowStream);
1908
+ if (recoverBtn)
1909
+ recoverBtn.addEventListener("click", recoverTicketFlow);
1262
1910
  if (refreshBtn)
1263
- refreshBtn.addEventListener("click", loadTicketFlow);
1911
+ refreshBtn.addEventListener("click", () => {
1912
+ void loadTicketFlow({ reason: "manual" });
1913
+ });
1914
+ const { overflowToggle, overflowDropdown, overflowNew, overflowRestart, overflowArchive } = els();
1915
+ if (overflowToggle && overflowDropdown) {
1916
+ const toggleMenu = (e) => {
1917
+ e.preventDefault();
1918
+ e.stopPropagation();
1919
+ const isHidden = overflowDropdown.classList.contains("hidden");
1920
+ overflowDropdown.classList.toggle("hidden", !isHidden);
1921
+ };
1922
+ const closeMenu = () => overflowDropdown.classList.add("hidden");
1923
+ overflowToggle.addEventListener("pointerdown", toggleMenu);
1924
+ overflowToggle.addEventListener("click", (e) => {
1925
+ e.preventDefault(); // swallow synthetic click after pointerdown
1926
+ });
1927
+ overflowToggle.addEventListener("keydown", (e) => {
1928
+ if (e.key === "Enter" || e.key === " ")
1929
+ toggleMenu(e);
1930
+ });
1931
+ // Close on outside click
1932
+ document.addEventListener("pointerdown", (e) => {
1933
+ if (!overflowDropdown.classList.contains("hidden") &&
1934
+ !overflowToggle.contains(e.target) &&
1935
+ !overflowDropdown.contains(e.target)) {
1936
+ closeMenu();
1937
+ }
1938
+ });
1939
+ }
1940
+ if (overflowNew) {
1941
+ overflowNew.addEventListener("click", () => {
1942
+ const newBtn = document.getElementById("ticket-new-btn");
1943
+ newBtn?.click();
1944
+ overflowDropdown?.classList.add("hidden");
1945
+ });
1946
+ }
1947
+ if (overflowRestart) {
1948
+ overflowRestart.addEventListener("click", () => {
1949
+ void restartTicketFlow();
1950
+ overflowDropdown?.classList.add("hidden");
1951
+ });
1952
+ }
1953
+ if (overflowArchive) {
1954
+ overflowArchive.addEventListener("click", () => {
1955
+ void archiveTicketFlow();
1956
+ overflowDropdown?.classList.add("hidden");
1957
+ });
1958
+ }
1264
1959
  // Initialize reason click handler for modal
1265
1960
  initReasonModal();
1266
1961
  // Initialize live output panel
1267
1962
  initLiveOutputPanel();
1268
1963
  // Initialize dispatch panel toggle for medium screens
1269
1964
  initDispatchPanelToggle();
1965
+ // Set up scroll listeners for fade indicator
1966
+ const ticketList = document.getElementById("ticket-flow-tickets");
1967
+ const dispatchHistory = document.getElementById("ticket-dispatch-history");
1968
+ [ticketList, dispatchHistory].forEach((el) => {
1969
+ if (el) {
1970
+ el.addEventListener("scroll", updateScrollFade, { passive: true });
1971
+ }
1972
+ });
1270
1973
  const newThreadBtn = document.getElementById("ticket-chat-new-thread");
1271
1974
  if (newThreadBtn) {
1272
1975
  newThreadBtn.addEventListener("click", async () => {
@@ -1278,8 +1981,10 @@ export function initTicketFlow() {
1278
1981
  initTicketEditor();
1279
1982
  loadTicketFlow();
1280
1983
  registerAutoRefresh("ticket-flow", {
1281
- callback: loadTicketFlow,
1282
- tabId: null,
1984
+ callback: async (ctx) => {
1985
+ await loadTicketFlow(ctx);
1986
+ },
1987
+ tabId: "tickets",
1283
1988
  interval: CONSTANTS.UI?.AUTO_REFRESH_INTERVAL ||
1284
1989
  15000,
1285
1990
  refreshOnActivation: true,
@@ -1295,6 +2000,24 @@ export function initTicketFlow() {
1295
2000
  subscribe("tickets:updated", () => {
1296
2001
  void loadTicketFiles();
1297
2002
  });
2003
+ // Update selection when editor opens a ticket
2004
+ subscribe("ticket-editor:opened", (payload) => {
2005
+ const data = payload;
2006
+ if (data?.path) {
2007
+ updateSelectedTicket(data.path);
2008
+ return;
2009
+ }
2010
+ if (data?.index != null && ticketListCache?.tickets?.length) {
2011
+ const match = ticketListCache.tickets.find((ticket) => ticket.index === data.index);
2012
+ if (match?.path) {
2013
+ updateSelectedTicket(match.path);
2014
+ }
2015
+ }
2016
+ });
2017
+ // Clear selection when editor is closed
2018
+ subscribe("ticket-editor:closed", () => {
2019
+ updateSelectedTicket(null);
2020
+ });
1298
2021
  // Handle browser navigation (back/forward)
1299
2022
  window.addEventListener("popstate", () => {
1300
2023
  const params = getUrlParams();