codex-autorunner 1.0.0__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 (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -2,8 +2,16 @@
2
2
  import { publish } from "./bus.js";
3
3
  import { escapeHtml, getUrlParams, updateUrlParams } from "./utils.js";
4
4
  const tabs = [];
5
+ const hamburgerActions = [];
6
+ let hamburgerMenuOpen = false;
7
+ let hamburgerMenuEl = null;
8
+ let hamburgerBtnEl = null;
9
+ let hamburgerBackdropEl = null;
5
10
  export function registerTab(id, label, opts = {}) {
6
- tabs.push({ id, label, hidden: Boolean(opts.hidden) });
11
+ tabs.push({ id, label, hidden: Boolean(opts.hidden), menuTab: Boolean(opts.menuTab), icon: opts.icon });
12
+ }
13
+ export function registerHamburgerAction(id, label, icon, onClick) {
14
+ hamburgerActions.push({ id, label, icon, onClick });
7
15
  }
8
16
  let setActivePanelFn = null;
9
17
  let pendingActivate = null;
@@ -15,24 +23,55 @@ export function activateTab(id) {
15
23
  pendingActivate = id;
16
24
  }
17
25
  }
26
+ function closeHamburgerMenu() {
27
+ if (!hamburgerMenuOpen)
28
+ return;
29
+ hamburgerMenuOpen = false;
30
+ hamburgerMenuEl?.classList.remove("open");
31
+ hamburgerBtnEl?.classList.remove("active");
32
+ hamburgerBackdropEl?.classList.remove("open");
33
+ }
34
+ function toggleHamburgerMenu() {
35
+ hamburgerMenuOpen = !hamburgerMenuOpen;
36
+ hamburgerMenuEl?.classList.toggle("open", hamburgerMenuOpen);
37
+ hamburgerBtnEl?.classList.toggle("active", hamburgerMenuOpen);
38
+ hamburgerBackdropEl?.classList.toggle("open", hamburgerMenuOpen);
39
+ }
40
+ function updateHamburgerActiveState(activeTabId) {
41
+ if (!hamburgerMenuEl)
42
+ return;
43
+ const items = hamburgerMenuEl.querySelectorAll(".hamburger-item[data-target]");
44
+ items.forEach((item) => {
45
+ const target = item.dataset.target;
46
+ item.classList.toggle("active", target === activeTabId);
47
+ });
48
+ // Also update hamburger button active state if a menu tab is active
49
+ const isMenuTabActive = tabs.some((t) => t.menuTab && t.id === activeTabId);
50
+ hamburgerBtnEl?.classList.toggle("has-active", isMenuTabActive);
51
+ }
18
52
  export function initTabs(defaultTab = "analytics") {
19
53
  const container = document.querySelector(".tabs");
54
+ const navBar = document.querySelector(".nav-bar");
20
55
  if (!container)
21
56
  return;
22
57
  container.innerHTML = "";
23
58
  const panels = document.querySelectorAll(".panel");
24
59
  const setActivePanel = (id) => {
25
60
  panels.forEach((p) => p.classList.toggle("active", p.id === id));
61
+ // Update primary tab buttons
26
62
  const buttons = container.querySelectorAll(".tab");
27
63
  buttons.forEach((btn) => btn.classList.toggle("active", btn.dataset.target === id));
64
+ // Update hamburger menu items
65
+ updateHamburgerActiveState(id);
28
66
  updateUrlParams({ tab: id });
29
67
  publish("tab:change", id);
30
68
  };
31
69
  setActivePanelFn = setActivePanel;
32
- tabs.forEach(tab => {
33
- if (tab.hidden) {
34
- return;
35
- }
70
+ // Separate primary tabs from menu tabs
71
+ const primaryTabs = tabs.filter((t) => !t.hidden && !t.menuTab);
72
+ const menuTabs = tabs.filter((t) => !t.hidden && t.menuTab);
73
+ // Render primary tabs
74
+ primaryTabs.forEach(tab => {
36
75
  const btn = document.createElement("button");
37
76
  btn.className = "tab";
38
77
  btn.dataset.target = tab.id;
@@ -43,20 +82,122 @@ export function initTabs(defaultTab = "analytics") {
43
82
  btn.addEventListener("click", () => setActivePanel(tab.id));
44
83
  container.appendChild(btn);
45
84
  });
85
+ // Create hamburger menu if there are menu tabs or actions
86
+ if (menuTabs.length > 0 || hamburgerActions.length > 0) {
87
+ const wrapper = document.createElement("div");
88
+ wrapper.className = "hamburger-wrapper";
89
+ // Hamburger button
90
+ const btn = document.createElement("button");
91
+ btn.className = "hamburger-btn";
92
+ btn.setAttribute("aria-label", "More options");
93
+ btn.setAttribute("aria-expanded", "false");
94
+ btn.innerHTML = `
95
+ <span class="hamburger-icon">
96
+ <span></span>
97
+ <span></span>
98
+ <span></span>
99
+ </span>
100
+ `;
101
+ hamburgerBtnEl = btn;
102
+ // Hamburger menu dropdown
103
+ const menu = document.createElement("div");
104
+ menu.className = "hamburger-menu";
105
+ menu.setAttribute("role", "menu");
106
+ hamburgerMenuEl = menu;
107
+ // Add menu tab items
108
+ menuTabs.forEach((tab) => {
109
+ const item = document.createElement("button");
110
+ item.className = "hamburger-item";
111
+ item.dataset.target = tab.id;
112
+ item.setAttribute("role", "menuitem");
113
+ const iconHtml = tab.icon ? `<span class="hamburger-item-icon">${tab.icon}</span>` : "";
114
+ item.innerHTML = `${iconHtml}<span>${escapeHtml(tab.label)}</span>`;
115
+ item.addEventListener("click", () => {
116
+ setActivePanel(tab.id);
117
+ closeHamburgerMenu();
118
+ });
119
+ menu.appendChild(item);
120
+ });
121
+ // Add divider if there are both tabs and actions
122
+ if (menuTabs.length > 0 && hamburgerActions.length > 0) {
123
+ const divider = document.createElement("div");
124
+ divider.className = "hamburger-divider";
125
+ menu.appendChild(divider);
126
+ }
127
+ // Add action items (like Settings)
128
+ hamburgerActions.forEach((action) => {
129
+ const item = document.createElement("button");
130
+ item.className = "hamburger-item";
131
+ item.dataset.action = action.id;
132
+ item.setAttribute("role", "menuitem");
133
+ const iconHtml = action.icon ? `<span class="hamburger-item-icon">${action.icon}</span>` : "";
134
+ item.innerHTML = `${iconHtml}<span>${escapeHtml(action.label)}</span>`;
135
+ item.addEventListener("click", () => {
136
+ action.onClick();
137
+ closeHamburgerMenu();
138
+ });
139
+ menu.appendChild(item);
140
+ });
141
+ // Mobile backdrop - appended to body for proper z-index stacking
142
+ const backdrop = document.createElement("div");
143
+ backdrop.className = "hamburger-backdrop";
144
+ backdrop.addEventListener("click", closeHamburgerMenu);
145
+ hamburgerBackdropEl = backdrop;
146
+ document.body.appendChild(backdrop);
147
+ // Append menu to body for mobile z-index stacking (above backdrop)
148
+ // On mobile, the nav-bar has z-index:100 which creates a stacking context
149
+ // that would trap the menu below the backdrop (z-index:1999)
150
+ document.body.appendChild(menu);
151
+ // Toggle menu on button click
152
+ const toggleHandler = (e) => {
153
+ e.stopPropagation();
154
+ // Prevent ghost clicks on touch devices
155
+ if (e.type === "touchend") {
156
+ e.preventDefault();
157
+ }
158
+ toggleHamburgerMenu();
159
+ btn.setAttribute("aria-expanded", String(hamburgerMenuOpen));
160
+ };
161
+ btn.addEventListener("click", toggleHandler);
162
+ btn.addEventListener("touchend", toggleHandler);
163
+ // Close menu on outside click (check both wrapper and menu since menu is in body)
164
+ document.addEventListener("click", (e) => {
165
+ if (hamburgerMenuOpen && !wrapper.contains(e.target) && !menu.contains(e.target)) {
166
+ closeHamburgerMenu();
167
+ }
168
+ });
169
+ // Close menu on Escape
170
+ document.addEventListener("keydown", (e) => {
171
+ if (e.key === "Escape" && hamburgerMenuOpen) {
172
+ closeHamburgerMenu();
173
+ hamburgerBtnEl?.focus();
174
+ }
175
+ });
176
+ wrapper.appendChild(btn);
177
+ // Insert hamburger after tabs or at the end of nav bar
178
+ const navActions = navBar?.querySelector(".nav-actions");
179
+ if (navActions) {
180
+ navBar?.insertBefore(wrapper, navActions);
181
+ }
182
+ else {
183
+ navBar?.appendChild(wrapper);
184
+ }
185
+ }
46
186
  const params = getUrlParams();
47
187
  const requested = params.get("tab");
48
- const initialTab = tabs.some((t) => t.id === requested)
188
+ const allVisibleTabs = tabs.filter((t) => !t.hidden);
189
+ const initialTab = allVisibleTabs.some((t) => t.id === requested)
49
190
  ? requested
50
- : tabs.some((t) => t.id === defaultTab)
191
+ : allVisibleTabs.some((t) => t.id === defaultTab)
51
192
  ? defaultTab
52
- : tabs[0]?.id;
193
+ : allVisibleTabs[0]?.id;
53
194
  if (initialTab) {
54
195
  setActivePanel(initialTab);
55
196
  }
56
- else if (tabs.length > 0) {
57
- setActivePanel(tabs[0].id);
197
+ else if (allVisibleTabs.length > 0) {
198
+ setActivePanel(allVisibleTabs[0].id);
58
199
  }
59
- if (pendingActivate && tabs.some((t) => t.id === pendingActivate)) {
200
+ if (pendingActivate && allVisibleTabs.some((t) => t.id === pendingActivate)) {
60
201
  const id = pendingActivate;
61
202
  pendingActivate = null;
62
203
  setActivePanel(id);
@@ -1,6 +1,10 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { TerminalManager } from "./terminalManager.js";
3
+ import { refreshAgentControls } from "./agentControls.js";
4
+ import { subscribe } from "./bus.js";
5
+ import { isRepoHealthy } from "./health.js";
3
6
  let terminalManager = null;
7
+ let terminalHealthRefreshInitialized = false;
4
8
  export function getTerminalManager() {
5
9
  return terminalManager;
6
10
  }
@@ -15,6 +19,7 @@ export function initTerminal() {
15
19
  }
16
20
  terminalManager = new TerminalManager();
17
21
  terminalManager.init();
22
+ initTerminalHealthRefresh();
18
23
  // Ensure terminal is resized to fit container after initialization
19
24
  if (terminalManager) {
20
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,3 +29,16 @@ export function initTerminal() {
24
29
  }
25
30
  }
26
31
  }
32
+ function initTerminalHealthRefresh() {
33
+ if (terminalHealthRefreshInitialized)
34
+ return;
35
+ terminalHealthRefreshInitialized = true;
36
+ subscribe("repo:health", (payload) => {
37
+ const status = payload?.status || "";
38
+ if (status !== "ok" && status !== "degraded")
39
+ return;
40
+ if (!isRepoHealthy())
41
+ return;
42
+ void refreshAgentControls({ reason: "background" });
43
+ });
44
+ }
@@ -30,6 +30,68 @@ const state = {
30
30
  // Autosave debounce timer
31
31
  const AUTOSAVE_DELAY_MS = 1000;
32
32
  let ticketDocEditor = null;
33
+ let ticketNavCache = [];
34
+ function isTypingTarget(target) {
35
+ if (!(target instanceof HTMLElement))
36
+ return false;
37
+ const tag = target.tagName;
38
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
39
+ }
40
+ async function fetchTicketList() {
41
+ const data = (await api("/api/flows/ticket_flow/tickets"));
42
+ const list = (data?.tickets || []).filter((ticket) => typeof ticket.index === "number");
43
+ list.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
44
+ return list;
45
+ }
46
+ async function updateTicketNavButtons() {
47
+ const { prevBtn, nextBtn } = els();
48
+ if (!prevBtn || !nextBtn)
49
+ return;
50
+ if (state.mode !== "edit" || state.ticketIndex == null) {
51
+ prevBtn.disabled = true;
52
+ nextBtn.disabled = true;
53
+ return;
54
+ }
55
+ try {
56
+ const list = await fetchTicketList();
57
+ ticketNavCache = list;
58
+ }
59
+ catch {
60
+ // If fetch fails, fall back to the last known list.
61
+ }
62
+ const list = ticketNavCache;
63
+ if (!list.length) {
64
+ prevBtn.disabled = true;
65
+ nextBtn.disabled = true;
66
+ return;
67
+ }
68
+ const idx = list.findIndex((ticket) => ticket.index === state.ticketIndex);
69
+ const hasPrev = idx > 0;
70
+ const hasNext = idx >= 0 && idx < list.length - 1;
71
+ prevBtn.disabled = !hasPrev;
72
+ nextBtn.disabled = !hasNext;
73
+ }
74
+ async function navigateTicket(delta) {
75
+ if (state.mode !== "edit" || state.ticketIndex == null)
76
+ return;
77
+ await performAutosave();
78
+ let list = ticketNavCache;
79
+ if (!list.length) {
80
+ try {
81
+ list = await fetchTicketList();
82
+ ticketNavCache = list;
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ }
88
+ const idx = list.findIndex((ticket) => ticket.index === state.ticketIndex);
89
+ const target = idx >= 0 ? list[idx + delta] : null;
90
+ if (target && target.index != null) {
91
+ openTicketEditor(target);
92
+ }
93
+ void updateTicketNavButtons();
94
+ }
33
95
  function els() {
34
96
  return {
35
97
  modal: document.getElementById("ticket-editor-modal"),
@@ -40,6 +102,8 @@ function els() {
40
102
  newBtn: document.getElementById("ticket-new-btn"),
41
103
  insertCheckboxBtn: document.getElementById("ticket-insert-checkbox"),
42
104
  undoBtn: document.getElementById("ticket-undo-btn"),
105
+ prevBtn: document.getElementById("ticket-nav-prev"),
106
+ nextBtn: document.getElementById("ticket-nav-next"),
43
107
  autosaveStatus: document.getElementById("ticket-autosave-status"),
44
108
  // Frontmatter form elements
45
109
  fmAgent: document.getElementById("ticket-fm-agent"),
@@ -532,13 +596,14 @@ export function openTicketEditor(ticket) {
532
596
  if (ticket?.index != null) {
533
597
  updateUrlParams({ ticket: ticket.index });
534
598
  }
535
- // Focus on title field for new tickets, body for existing
599
+ if (ticket?.path) {
600
+ publish("ticket-editor:opened", { path: ticket.path, index: ticket.index ?? null });
601
+ }
602
+ void updateTicketNavButtons();
603
+ // Focus on title field for new tickets
536
604
  if (state.mode === "create" && fmTitle) {
537
605
  fmTitle.focus();
538
606
  }
539
- else {
540
- content.focus();
541
- }
542
607
  }
543
608
  /**
544
609
  * Close the ticket editor modal (autosaves on close)
@@ -568,9 +633,12 @@ export function closeTicketEditor() {
568
633
  ticketDocEditor = null;
569
634
  // Clear ticket from URL
570
635
  updateUrlParams({ ticket: null });
636
+ void updateTicketNavButtons();
571
637
  // Reset chat state
572
638
  resetTicketChatState();
573
639
  setTicketIndex(null);
640
+ // Notify that editor was closed (for selection state cleanup)
641
+ publish("ticket-editor:closed", {});
574
642
  }
575
643
  /**
576
644
  * Save the current ticket (triggers immediate autosave)
@@ -618,7 +686,7 @@ export async function deleteTicket() {
618
686
  * Initialize the ticket editor - wire up event listeners
619
687
  */
620
688
  export function initTicketEditor() {
621
- const { modal, content, deleteBtn, closeBtn, newBtn, insertCheckboxBtn, undoBtn, fmAgent, fmModel, fmReasoning, fmDone, fmTitle, chatInput, chatSendBtn, chatCancelBtn, patchApplyBtn, patchDiscardBtn, agentSelect, modelSelect, reasoningSelect, } = els();
689
+ const { modal, content, deleteBtn, closeBtn, newBtn, insertCheckboxBtn, undoBtn, prevBtn, nextBtn, fmAgent, fmModel, fmReasoning, fmDone, fmTitle, chatInput, chatSendBtn, chatCancelBtn, patchApplyBtn, patchDiscardBtn, agentSelect, modelSelect, reasoningSelect, } = els();
622
690
  if (!modal)
623
691
  return;
624
692
  // Prevent double initialization
@@ -646,6 +714,16 @@ export function initTicketEditor() {
646
714
  insertCheckboxBtn.addEventListener("click", insertCheckbox);
647
715
  if (undoBtn)
648
716
  undoBtn.addEventListener("click", undoChange);
717
+ if (prevBtn)
718
+ prevBtn.addEventListener("click", (e) => {
719
+ e.preventDefault();
720
+ void navigateTicket(-1);
721
+ });
722
+ if (nextBtn)
723
+ nextBtn.addEventListener("click", (e) => {
724
+ e.preventDefault();
725
+ void navigateTicket(1);
726
+ });
649
727
  // Autosave on content changes
650
728
  if (content) {
651
729
  content.addEventListener("input", onContentChange);
@@ -718,6 +796,22 @@ export function initTicketEditor() {
718
796
  undoChange();
719
797
  }
720
798
  });
799
+ // Left/Right arrows navigate between tickets when editor is open and not typing
800
+ document.addEventListener("keydown", (e) => {
801
+ if (!state.isOpen)
802
+ return;
803
+ // Check for navigation keys
804
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
805
+ return;
806
+ // Don't interfere with typing
807
+ if (isTypingTarget(e.target))
808
+ return;
809
+ // Only allow Alt or no modifier (no Ctrl/Meta/Shift)
810
+ if (e.ctrlKey || e.metaKey || e.shiftKey)
811
+ return;
812
+ e.preventDefault();
813
+ void navigateTicket(e.key === "ArrowLeft" ? -1 : 1);
814
+ });
721
815
  // Enter key creates new TODO checkbox when on a checkbox line
722
816
  if (content) {
723
817
  content.addEventListener("keydown", (e) => {