@starlove/orbit 1.4.1 → 2.0.0

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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.4e6fbe20.js +0 -952
  204. package/dist/full/js/app.4e6fbe20.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -230
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -1,1516 +0,0 @@
1
- // Ticket detail drawer: renders the full ticket pane (header meta, AI plan,
2
- // status history, comments) and wires every editable surface — inline title
3
- // and description editors, meta selects, label management, parent epic, and
4
- // related cards. Also owns the small render helpers (status history,
5
- // comments, detail-card) that are only used inside the drawer.
6
-
7
- import {
8
- state,
9
- ticketsForProject,
10
- selectableStatesForProject,
11
- boardLabelCatalog,
12
- currentBoard,
13
- upsertTicket,
14
- } from "./state.js";
15
- import { drawerInner, drawer, $ } from "./dom.js";
16
- import {
17
- escapeHtml,
18
- formatDate,
19
- formatDateDetail,
20
- ticketLabel,
21
- canonicalTicketType,
22
- typeLabel,
23
- typeLabelLong,
24
- priorityLabel,
25
- priorityKeyFor,
26
- renderPreservedText,
27
- stateClassFor,
28
- cleanText,
29
- } from "./format.js";
30
- import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
31
- import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
32
- import { navigate } from "./router.js";
33
- import { api, withBoardQuery } from "./api.js";
34
- import { features } from "./config.js";
35
- import { toast } from "./toast.js";
36
- // `load()` is intentionally not used for drawer-local mutations. Reloading
37
- // /api/bootstrap rebuilds the whole app; these helpers patch the local ticket
38
- // cache and refresh only the surfaces that actually changed.
39
- import { renderProjectDetail } from "./settings.js";
40
- import { unreadCount, markRead } from "./unread.js";
41
- import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
42
- import { openArtifactDrawer } from "./artifact-drawer.js";
43
- import { closeIconSvg } from "./icons.js";
44
- import { handleTextareaIndentationKeydown } from "./text-editing.js";
45
- import { renderPanel, renderTogglePanel } from "./panel.js";
46
- import { attachTypeahead } from "./typeahead.js";
47
- import { attachTicketTypeahead } from "./ticket-typeahead.js";
48
-
49
- /**
50
- * Refresh the open drawer from the focused ticket context endpoint. When the
51
- * board card itself changed, also rebuild the board from the patched local
52
- * cache; otherwise leave the board DOM alone.
53
- */
54
- async function refreshTicketDetail(
55
- ticketId,
56
- { renderBoardAfter = false } = {},
57
- ) {
58
- const [context, statusHistory, commentsResult] = await Promise.all([
59
- api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
60
- api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
61
- api(withBoardQuery(`/api/tickets/${ticketId}/comments`)),
62
- ]);
63
- if (context.ticket?.id !== ticketId) return;
64
- upsertTicket(context.ticket);
65
- if (renderBoardAfter) renderBoard();
66
- if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId)
67
- return;
68
- await renderDetail({
69
- context,
70
- statusHistory,
71
- comments: commentsResult.comments || [],
72
- });
73
- }
74
-
75
- /**
76
- * PATCH a ticket then refresh only the board/detail surfaces that depend on
77
- * the changed ticket. This avoids the previous full /api/bootstrap reload for
78
- * every drawer edit.
79
- */
80
- async function patchTicket(ticketId, body) {
81
- await api(withBoardQuery(`/api/tickets/${ticketId}`), {
82
- method: "PATCH",
83
- body,
84
- });
85
- await refreshTicketDetail(ticketId, { renderBoardAfter: true });
86
- }
87
-
88
- /**
89
- * Replace a static node with an inline editor; commit on Enter / blur, cancel on Esc.
90
- * For multi-line fields, Ctrl/Cmd+Enter commits and plain Enter inserts a newline.
91
- *
92
- * Options:
93
- * - fieldName: short identifier used for aria-label / data attribute (e.g. "title").
94
- * - multiline: true for <textarea> + Ctrl+Enter commit; false for <input> + Enter commit.
95
- * - initialValue: text shown in the editor on open.
96
- * - ariaLabel: full label for screen readers.
97
- * - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
98
- * - sourceEvent: optional pointer/click event used to seed the textarea caret near the clicked text.
99
- * - rerender: async fn called on cancel / no-change to put the static node back.
100
- * - commit: async fn called with the new value when the user accepts a real change.
101
- * Receives the *raw* editor value; the caller is responsible for any
102
- * trimming, cleanText, or PATCH wiring.
103
- */
104
- function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
105
- if (
106
- !node ||
107
- !sourceEvent ||
108
- typeof sourceEvent.clientX !== "number" ||
109
- typeof sourceEvent.clientY !== "number"
110
- )
111
- return null;
112
-
113
- let range = null;
114
- if (document.caretPositionFromPoint) {
115
- const position = document.caretPositionFromPoint(
116
- sourceEvent.clientX,
117
- sourceEvent.clientY,
118
- );
119
- if (
120
- position?.offsetNode &&
121
- (position.offsetNode === node || node.contains(position.offsetNode))
122
- ) {
123
- range = document.createRange();
124
- range.setStart(node, 0);
125
- range.setEnd(position.offsetNode, position.offset);
126
- }
127
- } else if (document.caretRangeFromPoint) {
128
- const caretRange = document.caretRangeFromPoint(
129
- sourceEvent.clientX,
130
- sourceEvent.clientY,
131
- );
132
- if (
133
- caretRange?.startContainer &&
134
- (caretRange.startContainer === node ||
135
- node.contains(caretRange.startContainer))
136
- ) {
137
- range = document.createRange();
138
- range.setStart(node, 0);
139
- range.setEnd(caretRange.startContainer, caretRange.startOffset);
140
- }
141
- }
142
-
143
- if (!range) return null;
144
- return Math.max(0, Math.min(maxLength, range.toString().length));
145
- }
146
-
147
- function captureInlineEditScrollState(node) {
148
- const scrollContainer = node?.closest?.(".drawer-inner");
149
- if (!scrollContainer) return null;
150
- return {
151
- scrollContainer,
152
- nodeRect: node.getBoundingClientRect(),
153
- scrollTop: scrollContainer.scrollTop,
154
- };
155
- }
156
-
157
- function restoreInlineEditScroll(scrollState, editor) {
158
- if (!scrollState?.scrollContainer || !editor?.isConnected) return;
159
- const editorRect = editor.getBoundingClientRect();
160
- const nextScrollTop = Math.max(
161
- 0,
162
- scrollState.scrollContainer.scrollTop +
163
- (editorRect.top - scrollState.nodeRect.top),
164
- );
165
- if (Number.isFinite(nextScrollTop))
166
- scrollState.scrollContainer.scrollTop = nextScrollTop;
167
- else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
168
- }
169
-
170
- function focusWithoutScrolling(editor) {
171
- try {
172
- editor.focus({ preventScroll: true });
173
- } catch {
174
- editor.focus();
175
- }
176
- }
177
-
178
- export function startInlineEdit(node, opts) {
179
- if (!node || node.dataset.editing === "true") return;
180
- const {
181
- fieldName,
182
- multiline = true,
183
- initialValue = "",
184
- ariaLabel,
185
- emptyMessage,
186
- sourceEvent,
187
- rerender,
188
- commit: commitHandler,
189
- } = opts;
190
-
191
- const scrollState = multiline ? captureInlineEditScrollState(node) : null;
192
- const initialString = String(initialValue);
193
- const clickedCaretOffset = multiline
194
- ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length)
195
- : null;
196
-
197
- node.dataset.editing = "true";
198
-
199
- const editor = document.createElement(multiline ? "textarea" : "input");
200
- editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
201
- editor.value = initialString;
202
- if (fieldName) editor.setAttribute("data-editing-field", fieldName);
203
- if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
204
- if (!multiline) {
205
- editor.type = "text";
206
- editor.maxLength = 500;
207
- editor.autocomplete = "off";
208
- editor.spellcheck = true;
209
- } else {
210
- editor.rows = Math.max(3, initialString.split("\n").length + 1);
211
- const sourceHeight = node.getBoundingClientRect().height;
212
- if (Number.isFinite(sourceHeight) && sourceHeight > 0) {
213
- editor.style.minHeight = `${Math.ceil(sourceHeight)}px`;
214
- }
215
- }
216
-
217
- node.replaceWith(editor);
218
- focusWithoutScrolling(editor);
219
- if (!multiline) {
220
- editor.select();
221
- } else {
222
- const selectionStart = clickedCaretOffset ?? 0;
223
- editor.setSelectionRange(selectionStart, selectionStart);
224
- restoreInlineEditScroll(scrollState, editor);
225
- }
226
-
227
- let done = false;
228
-
229
- const stopWatchingOutsideClicks = () => {
230
- document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
231
- };
232
-
233
- // If a downstream render rebuilds the title block while the editor is open,
234
- // the editor is dropped along with everything else; only restore the saved
235
- // node when our editor is still connected to the DOM.
236
- const restoreNode = () => {
237
- if (editor.isConnected) editor.replaceWith(node);
238
- delete node.dataset.editing;
239
- };
240
-
241
- const finish = () => {
242
- done = true;
243
- stopWatchingOutsideClicks();
244
- restoreNode();
245
- };
246
-
247
- function handleOutsidePointerDown(event) {
248
- if (done || event.target === editor || editor.contains(event.target))
249
- return;
250
- // Some drawer/header surfaces are not focusable, so clicking them does not
251
- // reliably blur the input in every browser. Force the blur path used on
252
- // commit so the edit styling cannot get stuck.
253
- editor.blur();
254
- }
255
-
256
- document.addEventListener("pointerdown", handleOutsidePointerDown, true);
257
-
258
- const commit = async () => {
259
- if (done) return;
260
- const next = multiline ? editor.value : editor.value.trim();
261
- finish();
262
- if (String(next) === String(initialValue)) {
263
- if (rerender) await rerender();
264
- return;
265
- }
266
- if (emptyMessage && !next.trim()) {
267
- toast.warning(emptyMessage);
268
- if (rerender) await rerender();
269
- return;
270
- }
271
- if (commitHandler) await commitHandler(next);
272
- };
273
-
274
- const cancel = async () => {
275
- if (done) return;
276
- finish();
277
- if (rerender) await rerender();
278
- };
279
-
280
- editor.addEventListener("blur", commit);
281
- editor.addEventListener("keydown", (event) => {
282
- if (multiline && handleTextareaIndentationKeydown(event, editor)) return;
283
- if (event.key === "Escape") {
284
- event.preventDefault();
285
- void cancel();
286
- return;
287
- }
288
- if (event.key === "Enter") {
289
- if (!multiline) {
290
- event.preventDefault();
291
- void commit();
292
- } else if (event.metaKey || event.ctrlKey) {
293
- event.preventDefault();
294
- void commit();
295
- }
296
- }
297
- });
298
- }
299
-
300
- function promptArtifactPath({ currentPath = "", label = "Artifact" } = {}) {
301
- const next = window.prompt(
302
- `Local markdown/text path for ${label} (example: docs/test.md or ./docs/test.md)`,
303
- currentPath || "docs/",
304
- );
305
- if (next === null) return null;
306
- return cleanText(next).trim();
307
- }
308
-
309
- async function promptAndSaveArtifactPath({ ticketId, fieldName, currentPath, label }) {
310
- if (!ticketId || !fieldName) return null;
311
- const next = promptArtifactPath({ currentPath, label });
312
- if (next === null) return null;
313
- await patchTicket(ticketId, { [fieldName]: next });
314
- await refreshTicketDetail(ticketId);
315
- toast.success(next ? "Artifact path updated" : "Artifact path cleared");
316
- return next;
317
- }
318
-
319
- /** Wire inline editors on the ticket detail header (meta fields, labels). */
320
- function wireTicketDetailEditors(ticket, context) {
321
- const ticketId = ticket.id;
322
-
323
- const editTitle = () =>
324
- startInlineEdit(titleEl, {
325
- fieldName: "title",
326
- multiline: false,
327
- initialValue: ticket.title || "",
328
- ariaLabel: "Edit ticket title",
329
- emptyMessage: "Title cannot be empty",
330
- rerender: () => renderDetail(),
331
- commit: (next) => patchTicket(ticketId, { title: next }),
332
- });
333
-
334
- const titleEl = drawer.querySelector('[data-edit-field="title"]');
335
- if (titleEl) {
336
- titleEl.addEventListener("click", editTitle);
337
- titleEl.addEventListener("keydown", (event) => {
338
- if (event.key === "Enter" || event.key === " ") {
339
- event.preventDefault();
340
- editTitle();
341
- }
342
- });
343
- }
344
-
345
- const descEl = drawerInner.querySelector('[data-edit-field="description"]');
346
- const editDescription = (sourceEvent) =>
347
- startInlineEdit(descEl, {
348
- fieldName: "description",
349
- multiline: true,
350
- initialValue: ticket.description || "",
351
- ariaLabel: "Edit ticket description",
352
- sourceEvent,
353
- rerender: () => renderDetail(),
354
- commit: (next) => patchTicket(ticketId, { description: cleanText(next) }),
355
- });
356
-
357
- if (descEl) {
358
- descEl.addEventListener("click", (event) => {
359
- event.preventDefault();
360
- editDescription(event);
361
- });
362
- descEl.addEventListener("keydown", (event) => {
363
- if (event.key === "Enter" || event.key === " ") {
364
- event.preventDefault();
365
- editDescription();
366
- }
367
- });
368
- }
369
-
370
- // AI Plan / Implementation fields: each becomes its own click-to-edit
371
- // surface so they save independently on blur / Ctrl+Enter, matching the
372
- // description field. The "ai_plan" / "implementation_summary" /
373
- // "implementation_updates" data-edit-field selectors are emitted by the
374
- // detail body template below.
375
- const AI_FIELDS = [
376
- { key: "ai_plan", label: "AI Plan" },
377
- { key: "implementation_summary", label: "Work Summary" },
378
- {
379
- key: "implementation_updates",
380
- label: "Revisions",
381
- }
382
- ];
383
- for (const { key, label } of AI_FIELDS) {
384
- const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
385
- if (!fieldEl) continue;
386
- const edit = (sourceEvent) =>
387
- startInlineEdit(fieldEl, {
388
- fieldName: key,
389
- multiline: true,
390
- initialValue: ticket[key] || "",
391
- ariaLabel: `Edit ${label}`,
392
- sourceEvent,
393
- rerender: () => renderDetail(),
394
- commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) }),
395
- });
396
- fieldEl.addEventListener("click", (event) => {
397
- event.preventDefault();
398
- edit(event);
399
- });
400
- fieldEl.addEventListener("keydown", (event) => {
401
- if (event.key === "Enter" || event.key === " ") {
402
- event.preventDefault();
403
- edit();
404
- }
405
- });
406
- }
407
-
408
- drawer.querySelectorAll(".meta-select[data-meta-field]").forEach((select) => {
409
- select.addEventListener("change", async () => {
410
- const field = select.dataset.metaField;
411
- if (field === "type") {
412
- await patchTicket(ticketId, { type: select.value });
413
- return;
414
- }
415
- if (field === "state_id") {
416
- await patchTicket(ticketId, { state_id: select.value });
417
- return;
418
- }
419
- if (field === "parent_ticket_id") {
420
- await patchTicket(ticketId, { parent_ticket_id: select.value || null });
421
- return;
422
- }
423
- if (field === "priority") {
424
- await patchTicket(ticketId, { priority: Number(select.value) });
425
- }
426
- });
427
- });
428
-
429
- const applyLabelPatch = async (nextNames) => {
430
- await patchTicket(ticketId, { labels: nextNames });
431
- };
432
-
433
- drawerInner.querySelectorAll("[data-remove-label]").forEach((btn) => {
434
- btn.addEventListener("click", async () => {
435
- const removeName = btn.getAttribute("data-remove-label");
436
- const names = (ticket.labels || [])
437
- .map((l) => l.name)
438
- .filter((n) => n !== removeName);
439
- await applyLabelPatch(names);
440
- });
441
- });
442
-
443
- const addLabel = async () => {
444
- const input = drawerInner.querySelector("[data-label-input]");
445
- if (!input) return;
446
- const name = input.value.trim();
447
- if (!name) return;
448
- const names = (ticket.labels || []).map((l) => l.name);
449
- if (names.includes(name)) {
450
- input.value = "";
451
- return;
452
- }
453
- names.push(name);
454
- input.value = "";
455
- await applyLabelPatch(names);
456
- };
457
-
458
- drawerInner
459
- .querySelector("[data-label-add]")
460
- ?.addEventListener("click", () => {
461
- void addLabel();
462
- });
463
- // Attach the typeahead first so its Enter handler runs before the add-on-Enter
464
- // handler below; when a suggestion is highlighted it stops propagation so the
465
- // partial typed text isn't added instead.
466
- attachLabelTypeahead(drawerInner.querySelector("[data-label-input]"), ticket);
467
- drawerInner
468
- .querySelector("[data-label-input]")
469
- ?.addEventListener("keydown", (event) => {
470
- if (event.key === "Enter") {
471
- event.preventDefault();
472
- void addLabel();
473
- }
474
- });
475
- }
476
-
477
- export async function renderDetail(options = {}) {
478
- if (state.detailMode === "settings") {
479
- await renderProjectDetail();
480
- openDrawer();
481
- return;
482
- }
483
-
484
- if (!state.selectedTicketId) {
485
- closeDrawer();
486
- return;
487
- }
488
-
489
- const requestedTicketId = state.selectedTicketId;
490
- const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
491
- const [context, statusHistory, comments, attachmentList] = await Promise.all([
492
- hasOption("context")
493
- ? Promise.resolve(options.context)
494
- : api(
495
- withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`),
496
- ),
497
- hasOption("statusHistory")
498
- ? Promise.resolve(options.statusHistory)
499
- : api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
500
- hasOption("comments")
501
- ? Promise.resolve(options.comments)
502
- : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then(
503
- (result) => result.comments || [],
504
- ),
505
- hasOption("attachmentList")
506
- ? Promise.resolve(options.attachmentList)
507
- : features.attachments
508
- ? api(
509
- withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`),
510
- ).catch(() => ({ attachments: [] }))
511
- : Promise.resolve({ attachments: [] }),
512
- ]);
513
- if (
514
- state.detailMode !== "ticket" ||
515
- state.selectedTicketId !== requestedTicketId
516
- )
517
- return;
518
- if (context.ticket?.id !== requestedTicketId) return;
519
- const ticket = context.ticket;
520
- // Acknowledge the read receipt: clear the unread dot on this card.
521
- // Also wipe the dot from the already-rendered card in the board so the
522
- // user doesn't see it lingering until the next full re-render.
523
- markRead(ticket.id, ticket.updated_at);
524
- document
525
- .querySelector(
526
- `.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`,
527
- )
528
- ?.remove();
529
- const states = selectableStatesForProject(ticket.state_id);
530
-
531
- // Ordered by the canonical hierarchy so the dropdown visually reinforces scale:
532
- // Epic (biggest) → Feature → Task → Bug.
533
- const typeOptions = ["epic", "feature", "task", "bug"]
534
- .map(
535
- (value) =>
536
- `<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`,
537
- )
538
- .join("");
539
-
540
- const stateOptions = states
541
- .map(
542
- (s) =>
543
- `<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`,
544
- )
545
- .join("");
546
-
547
- const priorityOptions = [4, 3, 2, 1, 0]
548
- .map(
549
- (p) =>
550
- `<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`,
551
- )
552
- .join("");
553
-
554
- const labelPills = (ticket.labels || [])
555
- .map(
556
- (label) => `
557
- <span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
558
- ${escapeHtml(label.name)}
559
- <button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label" aria-label="Remove label">${closeIconSvg}</button>
560
- </span>`,
561
- )
562
- .join("");
563
-
564
- const detailCanonicalType = canonicalTicketType(ticket.type);
565
- const priorityKey = priorityKeyFor(ticket.priority);
566
- const currentState = states.find((s) => s.id === ticket.state_id);
567
- const stateClass = stateClassFor(currentState);
568
- const descriptionHtml = ticket.description
569
- ? renderPreservedText(ticket.description)
570
- : escapeHtml("No description yet.");
571
- const descriptionClass = ticket.description
572
- ? "description preserved-text-body editable-field detail-description-body"
573
- : "description editable-field detail-description-body is-placeholder";
574
- const detailSubtitleHtml = `
575
- <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
576
- <div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
577
- <select class="detail-meta-badge detail-state-badge meta-select" data-variant="${escapeHtml(stateClass)}" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
578
- <select class="detail-meta-badge detail-type-badge type-pill-${escapeHtml(detailCanonicalType)} meta-select" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select>
579
- <select class="detail-meta-badge detail-priority-badge priority-pill meta-select" data-variant="${escapeHtml(priorityKey)}" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
580
- </div>
581
- `;
582
-
583
- renderDrawerShell({
584
- mode: "ticket",
585
- title: ticket.title,
586
- titleAttrs: {
587
- class: "editable-field",
588
- "data-edit-field": "title",
589
- role: "button",
590
- tabindex: "0",
591
- title: "Click or press Enter to edit ticket title",
592
- "aria-label": "Edit ticket title",
593
- },
594
- subtitleHtml: detailSubtitleHtml,
595
- body: `
596
- <div class="detail-head">
597
- <p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
598
- <div class="${descriptionClass}" data-edit-field="description" tabindex="0" title="Click to edit">${descriptionHtml}</div>
599
- </div>
600
-
601
- <div class="settings-stack detail-section-stack">
602
- ${renderPanel({
603
- title: "Labels",
604
- body: `
605
- <div class="settings-row">
606
- <div class="label-pills-row" data-other-labels>${labelPills}</div>
607
- <div class="label-add-row">
608
- <div class="label-add-input-wrap">
609
- <input type="text" class="label-add-input meta-inline-input" data-label-input autocomplete="off" placeholder="Add label…" aria-label="Add label" />
610
- </div>
611
- <button type="button" class="meta-add-btn detail-action-btn" data-variant="ghost" data-label-add>Add</button>
612
- </div>
613
- </div>
614
- `,
615
- })}
616
-
617
- ${renderHierarchySection(ticket, context)}
618
-
619
- ${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
620
-
621
- ${renderParentEpicSection(ticket, context)}
622
-
623
- ${renderPanel({
624
- title: "Related",
625
- body: `<div class="settings-row">${renderRelated(context.relations, ticket)}</div>`,
626
- })}
627
-
628
- ${
629
- currentBoard()?.ai_enabled !== 0
630
- ? renderTogglePanel({
631
- title: "AI Plan / Implementation Record",
632
- open: aiPlanOpen(ticket.id),
633
- attrs: `data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"`,
634
- body: `
635
- <div class="ai-fields-grid">
636
- ${renderInlinePreservedTextField({
637
- fieldName: "ai_plan",
638
- label: "AI Plan",
639
- value: ticket.ai_plan,
640
- placeholder: "What to do: ",
641
- ticketId: ticket.id,
642
- artifactPath: ticket.plan_artifact_path,
643
- artifactFieldName: "plan_artifact_path",
644
- artifactLabel: "Open full handoff",
645
- })}
646
- ${renderInlinePreservedTextField({
647
- fieldName: "implementation_summary",
648
- label: "Work Summary",
649
- value: ticket.implementation_summary,
650
- placeholder: "What was done: ",
651
- ticketId: ticket.id,
652
- artifactPath: ticket.implementation_artifact_path,
653
- artifactFieldName: "implementation_artifact_path",
654
- artifactLabel: "Open full report",
655
- })}
656
- ${renderInlinePreservedTextField({
657
- fieldName: "implementation_updates",
658
- label: "Revisions",
659
- value: ticket.implementation_updates,
660
- placeholder: "Changes since initial implementation: ",
661
- })}
662
- </div>`,
663
- })
664
- : ""
665
- }
666
-
667
- ${renderPanel({
668
- title: "Status History",
669
- body: `<div class="settings-row">${renderStatusHistory(statusHistory)}</div>`,
670
- })}
671
-
672
- ${renderPanel({
673
- title: "Comments",
674
- body: `
675
- <div class="settings-row">
676
- ${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
677
- <form class="comment-form" id="detailCommentForm">
678
- <textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
679
- <button type="submit" class="detail-action-btn" data-variant="ghost">Comment</button>
680
- </form>
681
- </div>
682
- `,
683
- })}
684
- </div>
685
- `,
686
- });
687
-
688
- wireTicketDetailEditors(ticket, context);
689
- if (features.attachments) wireAttachmentControls(ticket);
690
-
691
- const commentTextarea = $("#detailCommentForm textarea");
692
- commentTextarea?.addEventListener("keydown", (event) => {
693
- handleTextareaIndentationKeydown(event, commentTextarea);
694
- });
695
-
696
- $("#detailCommentForm").addEventListener("submit", async (event) => {
697
- event.preventDefault();
698
- const body = cleanText(
699
- new FormData(event.currentTarget).get("body"),
700
- ).trim();
701
- if (!body) return;
702
- await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
703
- method: "POST",
704
- body: { body },
705
- });
706
- await refreshTicketDetail(ticket.id);
707
- });
708
-
709
- drawerInner.querySelectorAll("[data-comment-action='toggle-ai-omission']").forEach((button) => {
710
- button.addEventListener("click", async () => {
711
- const commentId = button.dataset.commentId;
712
- if (!commentId) return;
713
- const omittedForAi = button.dataset.omittedForAi !== "true";
714
- await api(withBoardQuery(`/api/tickets/${ticket.id}/comments/${commentId}`), {
715
- method: "PATCH",
716
- body: { omitted_for_ai: omittedForAi },
717
- });
718
- toast.info(omittedForAi ? "Comment omitted from AI context" : "Comment included in AI context");
719
- await refreshTicketDetail(ticket.id);
720
- });
721
- });
722
-
723
- drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
724
- card.addEventListener("click", async (event) => {
725
- if (
726
- event.target.closest(
727
- "[data-detach-child],[data-detach-parent],[data-detach-relation]",
728
- )
729
- )
730
- return;
731
- navigate({
732
- boardId: state.boardId,
733
- view: "ticket",
734
- ticketId: card.dataset.openTicket,
735
- });
736
- });
737
- });
738
-
739
- drawerInner.querySelectorAll("[data-detach-child]").forEach((btn) => {
740
- btn.addEventListener("click", async (event) => {
741
- event.stopPropagation();
742
- const childId = btn.dataset.detachChild;
743
- try {
744
- await api(withBoardQuery(`/api/tickets/${childId}`), {
745
- method: "PATCH",
746
- body: { parent_ticket_id: null },
747
- });
748
- } catch (err) {
749
- toast.error(err.message || "Failed to remove child");
750
- return;
751
- }
752
- await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
753
- toast.success("Removed from epic");
754
- });
755
- });
756
-
757
- drawerInner.querySelectorAll("[data-detach-parent]").forEach((btn) => {
758
- btn.addEventListener("click", async (event) => {
759
- event.stopPropagation();
760
- try {
761
- await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
762
- method: "PATCH",
763
- body: { parent_ticket_id: null },
764
- });
765
- } catch (err) {
766
- toast.error(err.message || "Failed to remove parent");
767
- return;
768
- }
769
- await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
770
- toast.success("Parent epic removed");
771
- });
772
- });
773
-
774
- drawerInner.querySelectorAll("[data-detach-relation]").forEach((btn) => {
775
- btn.addEventListener("click", async (event) => {
776
- event.stopPropagation();
777
- const relationId = btn.dataset.detachRelation;
778
- try {
779
- await api(withBoardQuery(`/api/relations/${relationId}`), {
780
- method: "DELETE",
781
- });
782
- } catch (err) {
783
- toast.error(err.message || "Failed to remove relation");
784
- return;
785
- }
786
- await refreshTicketDetail(ticket.id);
787
- toast.success("Relation removed");
788
- });
789
- });
790
-
791
- const aiPlanEl = drawerInner.querySelector("[data-ai-plan-toggle]");
792
- if (aiPlanEl) {
793
- aiPlanEl.addEventListener("toggle", () => {
794
- const id = aiPlanEl.dataset.ticketId;
795
- if (aiPlanEl.open) localStorage.setItem(`mab_ai_plan_open:${id}`, "1");
796
- else localStorage.removeItem(`mab_ai_plan_open:${id}`);
797
- });
798
- }
799
-
800
- drawerInner.querySelectorAll("[data-open-artifact]").forEach((btn) => {
801
- btn.addEventListener("click", async (event) => {
802
- event.stopPropagation();
803
- const artifactFieldName = btn.dataset.artifactField;
804
- const artifactTitle = btn.dataset.artifactTitle || "Linked artifact";
805
- const artifactTicketId = btn.dataset.artifactTicketId || ticket.id;
806
- const currentPath = String(btn.dataset.openArtifact || "").trim();
807
- if (!currentPath) {
808
- await promptAndSaveArtifactPath({
809
- ticketId: artifactTicketId,
810
- fieldName: artifactFieldName,
811
- currentPath,
812
- label: artifactTitle,
813
- });
814
- return;
815
- }
816
- const opened = await openArtifactDrawer({
817
- ticketId: artifactTicketId,
818
- path: currentPath,
819
- title: artifactTitle,
820
- });
821
- if (opened === false && artifactFieldName) {
822
- await promptAndSaveArtifactPath({
823
- ticketId: artifactTicketId,
824
- fieldName: artifactFieldName,
825
- currentPath,
826
- label: artifactTitle,
827
- });
828
- }
829
- });
830
- });
831
-
832
- const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
833
- if (parentEpicForm) {
834
- const parentInput = parentEpicForm.querySelector("[name=target]");
835
- const parentClear = parentEpicForm.querySelector(
836
- "[data-parent-epic-clear]",
837
- );
838
- attachTicketTypeahead(parentInput, () => epicCandidates(ticket));
839
- const syncParentClear = () => {
840
- parentEpicForm.classList.toggle(
841
- "has-value",
842
- parentInput.value.length > 0,
843
- );
844
- };
845
- parentInput.addEventListener("input", syncParentClear);
846
- syncParentClear();
847
- parentClear.addEventListener("click", () => {
848
- parentInput.value = "";
849
- syncParentClear();
850
- parentInput.focus();
851
- });
852
- parentEpicForm.addEventListener("submit", async (event) => {
853
- event.preventDefault();
854
- const targetLabel = String(
855
- new FormData(event.currentTarget).get("target") || "",
856
- ).trim();
857
- if (!targetLabel) return;
858
- const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
859
- if (!targetId) {
860
- toast.warning("Pick an epic from the suggestions");
861
- return;
862
- }
863
- const target = ticketsForProject().find((t) => t.id === targetId);
864
- if (!target || canonicalTicketType(target.type) !== "epic") {
865
- toast.warning("Parent must be an epic");
866
- return;
867
- }
868
- try {
869
- await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
870
- method: "PATCH",
871
- body: { parent_ticket_id: targetId },
872
- });
873
- } catch (err) {
874
- toast.error(err.message || "Failed to set parent epic");
875
- return;
876
- }
877
- await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
878
- toast.success("Parent epic set");
879
- });
880
- }
881
-
882
- const relatedAddForm = drawerInner.querySelector("#relatedAddForm");
883
- if (relatedAddForm) {
884
- const relatedInput = relatedAddForm.querySelector("[name=target]");
885
- const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
886
- attachTicketTypeahead(relatedInput, () =>
887
- relatedTicketCandidates(context.relations, ticket),
888
- );
889
- const syncRelatedClear = () => {
890
- relatedAddForm.classList.toggle(
891
- "has-value",
892
- relatedInput.value.length > 0,
893
- );
894
- };
895
- relatedInput.addEventListener("input", syncRelatedClear);
896
- syncRelatedClear();
897
- relatedClear.addEventListener("click", () => {
898
- relatedInput.value = "";
899
- syncRelatedClear();
900
- relatedInput.focus();
901
- });
902
- relatedAddForm.addEventListener("submit", async (event) => {
903
- event.preventDefault();
904
- const form = new FormData(event.currentTarget);
905
- const targetLabel = String(form.get("target") || "").trim();
906
- const relationType = String(form.get("type") || "relates_to");
907
- if (!targetLabel) return;
908
- const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
909
- if (!targetId) {
910
- toast.warning("Pick a ticket from the suggestions");
911
- return;
912
- }
913
- try {
914
- await api(withBoardQuery(`/api/relations`), {
915
- method: "POST",
916
- body: {
917
- source_ticket_id: ticket.id,
918
- target_ticket_id: targetId,
919
- type: relationType,
920
- },
921
- });
922
- } catch (err) {
923
- toast.error(err.message || "Failed to add relation");
924
- return;
925
- }
926
- await refreshTicketDetail(ticket.id);
927
- });
928
- }
929
-
930
- // Reverse of "Set parent epic": from an epic, pull a feature/task in as a
931
- // child by patching that ticket's parent_ticket_id to this epic.
932
- const epicChildForm = drawerInner.querySelector("#epicChildForm");
933
- if (epicChildForm) {
934
- const childInput = epicChildForm.querySelector("[name=target]");
935
- const childClear = epicChildForm.querySelector("[data-epic-child-clear]");
936
- const syncChildClear = () => {
937
- epicChildForm.classList.toggle("has-value", childInput.value.length > 0);
938
- };
939
- childInput.addEventListener("input", syncChildClear);
940
- syncChildClear();
941
- childClear.addEventListener("click", () => {
942
- childInput.value = "";
943
- syncChildClear();
944
- childInput.focus();
945
- });
946
- attachTicketTypeahead(childInput, () => childCandidates(ticket));
947
- epicChildForm.addEventListener("submit", async (event) => {
948
- event.preventDefault();
949
- const targetLabel = String(
950
- new FormData(event.currentTarget).get("target") || "",
951
- ).trim();
952
- if (!targetLabel) return;
953
- const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
954
- if (!targetId) {
955
- toast.warning("Pick a ticket from the suggestions");
956
- return;
957
- }
958
- const target = ticketsForProject().find((t) => t.id === targetId);
959
- if (!target || canonicalTicketType(target.type) === "epic") {
960
- toast.warning("Epics can't be nested inside an epic");
961
- return;
962
- }
963
- try {
964
- await api(withBoardQuery(`/api/tickets/${targetId}`), {
965
- method: "PATCH",
966
- body: { parent_ticket_id: ticket.id },
967
- });
968
- } catch (err) {
969
- toast.error(err.message || "Failed to add feature");
970
- return;
971
- }
972
- await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
973
- toast.success("Feature added to epic");
974
- });
975
- }
976
-
977
- openDrawer();
978
- }
979
-
980
- function aiPlanOpen(ticketId) {
981
- return localStorage.getItem(`mab_ai_plan_open:${ticketId}`) === "1";
982
- }
983
-
984
- function renderAttachmentSection(ticket, attachments = []) {
985
- const cards = attachments.length
986
- ? `<div class="attachment-grid">${attachments.map((attachment) => renderAttachmentCard(ticket, attachment)).join("")}</div>`
987
- : `<p class="description attachment-empty-state">No images attached yet.</p>`;
988
- return renderPanel({
989
- title: "Images",
990
- className: "attachments-panel",
991
- attrs: `data-attachment-section data-ticket-id="${escapeHtml(ticket.id)}"`,
992
- body: `
993
- <div class="settings-row">
994
- <div class="attachment-dropzone" data-attachment-dropzone tabindex="0">
995
- <strong>Paste, drag, or upload images</strong>
996
- <span>PNG, JPG, GIF, WebP, SVG, BMP, or AVIF up to 10 MB each.</span>
997
- </div>
998
- ${cards}
999
- <label class="attachment-upload-button detail-action-btn" role="button" data-variant="ghost" tabindex="0">
1000
- Upload
1001
- <input type="file" accept="image/*" multiple data-attachment-input />
1002
- </label>
1003
- </div>
1004
- `,
1005
- });
1006
- }
1007
-
1008
- function renderAttachmentCard(ticket, attachment) {
1009
- const name = escapeHtml(attachment.original_name || "image");
1010
- const meta = `${formatBytes(attachment.size_bytes)} · ${escapeHtml(attachment.mime_type || "image")}`;
1011
- if (attachment.missing) {
1012
- return `
1013
- <article class="attachment-card is-missing">
1014
- <div class="attachment-missing-thumb">Missing</div>
1015
- <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
1016
- <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove missing image">${closeIconSvg}</button>
1017
- </article>
1018
- `;
1019
- }
1020
- const src = withBoardQuery(
1021
- attachment.content_url ||
1022
- `/api/tickets/${ticket.id}/attachments/${attachment.id}/content`,
1023
- );
1024
- return `
1025
- <article class="attachment-card">
1026
- <button type="button" class="attachment-thumb-button" data-no-arc data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
1027
- <img src="${escapeHtml(src)}" alt="${name}" loading="lazy" />
1028
- </button>
1029
- <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
1030
- <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove image">${closeIconSvg}</button>
1031
- </article>
1032
- `;
1033
- }
1034
-
1035
- function formatBytes(value) {
1036
- const bytes = Number(value || 0);
1037
- if (bytes < 1024) return `${bytes} B`;
1038
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1039
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1040
- }
1041
-
1042
- function imageFilesFromList(files) {
1043
- return [...(files || [])].filter(
1044
- (file) => file && String(file.type || "").startsWith("image/"),
1045
- );
1046
- }
1047
-
1048
- async function uploadAttachmentFiles(ticket, files) {
1049
- const imageFiles = imageFilesFromList(files);
1050
- if (!imageFiles.length) {
1051
- toast.warning("No image files found");
1052
- return;
1053
- }
1054
- for (const file of imageFiles) {
1055
- const response = await fetch(
1056
- withBoardQuery(
1057
- `/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`,
1058
- ),
1059
- {
1060
- method: "POST",
1061
- headers: {
1062
- "Content-Type": file.type || "application/octet-stream",
1063
- "X-File-Name": file.name || "image",
1064
- },
1065
- body: file,
1066
- },
1067
- );
1068
- if (!response.ok) {
1069
- let payload = {};
1070
- try {
1071
- payload = await response.json();
1072
- } catch {}
1073
- throw new Error(payload.error || `Upload failed (${response.status})`);
1074
- }
1075
- }
1076
- await refreshTicketDetail(ticket.id);
1077
- toast.success(
1078
- imageFiles.length === 1
1079
- ? "Image attached"
1080
- : `${imageFiles.length} images attached`,
1081
- );
1082
- }
1083
-
1084
- function wireAttachmentControls(ticket) {
1085
- const section = drawerInner.querySelector("[data-attachment-section]");
1086
- if (!section) return;
1087
- const input = section.querySelector("[data-attachment-input]");
1088
- input?.addEventListener("change", async () => {
1089
- try {
1090
- await uploadAttachmentFiles(ticket, input.files);
1091
- input.value = "";
1092
- } catch (err) {
1093
- toast.error(err.message || "Upload failed");
1094
- }
1095
- });
1096
-
1097
- const dropzone = section.querySelector("[data-attachment-dropzone]");
1098
- dropzone?.addEventListener("dragover", (event) => {
1099
- event.preventDefault();
1100
- dropzone.classList.add("is-dragover");
1101
- });
1102
- dropzone?.addEventListener("dragleave", () =>
1103
- dropzone.classList.remove("is-dragover"),
1104
- );
1105
- dropzone?.addEventListener("drop", async (event) => {
1106
- event.preventDefault();
1107
- dropzone.classList.remove("is-dragover");
1108
- try {
1109
- await uploadAttachmentFiles(ticket, event.dataTransfer?.files || []);
1110
- } catch (err) {
1111
- toast.error(err.message || "Upload failed");
1112
- }
1113
- });
1114
-
1115
- drawerInner.onpaste = async (event) => {
1116
- if (event.target.closest("input, textarea, [contenteditable='true']"))
1117
- return;
1118
- const files = imageFilesFromList(event.clipboardData?.files || []);
1119
- if (!files.length) return;
1120
- event.preventDefault();
1121
- try {
1122
- await uploadAttachmentFiles(ticket, files);
1123
- } catch (err) {
1124
- toast.error(err.message || "Paste upload failed");
1125
- }
1126
- };
1127
-
1128
- section.querySelectorAll("[data-delete-attachment]").forEach((btn) => {
1129
- btn.addEventListener("click", async () => {
1130
- try {
1131
- await api(
1132
- withBoardQuery(
1133
- `/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`,
1134
- ),
1135
- { method: "DELETE" },
1136
- );
1137
- await refreshTicketDetail(ticket.id);
1138
- toast.success("Image removed");
1139
- } catch (err) {
1140
- toast.error(err.message || "Delete failed");
1141
- }
1142
- });
1143
- });
1144
-
1145
- section.querySelectorAll("[data-open-lightbox]").forEach((btn) => {
1146
- btn.addEventListener("click", () =>
1147
- openAttachmentLightbox(
1148
- btn.dataset.openLightbox,
1149
- btn.dataset.lightboxTitle || "Image",
1150
- ),
1151
- );
1152
- });
1153
- }
1154
-
1155
- function openAttachmentLightbox(src, title) {
1156
- const existing = document.querySelector(".attachment-lightbox");
1157
- existing?.remove();
1158
- const overlay = document.createElement("div");
1159
- overlay.className = "attachment-lightbox";
1160
- overlay.innerHTML = `
1161
- <div class="attachment-lightbox-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
1162
- <button type="button" class="attachment-lightbox-close" aria-label="Close image">${closeIconSvg}</button>
1163
- <img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" />
1164
- <p>${escapeHtml(title)}</p>
1165
- </div>
1166
- `;
1167
- const close = () => {
1168
- document.removeEventListener("keydown", onKeyDown);
1169
- overlay.remove();
1170
- };
1171
- function onKeyDown(event) {
1172
- if (event.key === "Escape") close();
1173
- }
1174
- overlay.addEventListener("click", (event) => {
1175
- if (
1176
- event.target === overlay ||
1177
- event.target.closest(".attachment-lightbox-close")
1178
- )
1179
- close();
1180
- });
1181
- document.addEventListener("keydown", onKeyDown);
1182
- document.body.append(overlay);
1183
- }
1184
-
1185
- function resolveTicketIdFromLabel(label, selfId) {
1186
- const trimmed = label.trim();
1187
- const match = trimmed.match(/^#?(\d+)\b/);
1188
- const tickets = ticketsForProject().filter((t) => t.id !== selfId);
1189
- if (match) {
1190
- const n = Number(match[1]);
1191
- const hit = tickets.find((t) => t.number === n);
1192
- if (hit) return hit.id;
1193
- }
1194
- const exact = tickets.find((t) => `#${t.number} — ${t.title}` === trimmed);
1195
- return exact ? exact.id : "";
1196
- }
1197
-
1198
- function renderStatusHistory(history) {
1199
- if (!history || history.length === 0) {
1200
- return `<p class="description">No status changes yet.</p>`;
1201
- }
1202
- return history
1203
- .map((event) => {
1204
- const body = event.body;
1205
- const from = body.from ? escapeHtml(body.from) : "?";
1206
- const to = escapeHtml(body.to || body.state || body.next_state || "?");
1207
- const byLine = escapeHtml(
1208
- formatActorLabel({
1209
- actor: event.actor,
1210
- actorType: body.actor_type || "human",
1211
- actorId: body.actor_id,
1212
- }),
1213
- );
1214
- return `<div class="comment status-change">
1215
- <div class="comment-meta">
1216
- <span class="status-route">${from} → ${to}</span>
1217
- <span>${byLine} · ${formatDate(event.created_at)}</span>
1218
- </div>
1219
- </div>`;
1220
- })
1221
- .join("");
1222
- }
1223
-
1224
- function renderComment(comment) {
1225
- const aiEnabled = currentBoard()?.ai_enabled !== 0;
1226
- const omittedForAi = Boolean(comment.omitted_for_ai);
1227
- const kindLabel =
1228
- comment.kind === "human_comment" ? "" : escapeHtml(comment.kind.replace(/_/g, " "));
1229
- return `
1230
- <div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""} ${aiEnabled && omittedForAi ? "is-ai-omitted" : ""}">
1231
- <div class="comment-head">
1232
- <div class="comment-head-main">
1233
- <div class="comment-head-title">
1234
- <strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
1235
- ${kindLabel ? `<span class="comment-kind-pill">${kindLabel}</span>` : ""}
1236
- </div>
1237
- </div>
1238
- </div>
1239
- <div class="comment-body preserved-text-body">${renderPreservedText(comment.body)}</div>
1240
- <div class="comment-footer">
1241
- <span class="comment-footer-meta">${formatDate(comment.created_at)}</span>
1242
- ${
1243
- aiEnabled
1244
- ? `<button
1245
- type="button"
1246
- class="comment-ai-toggle detail-action-btn"
1247
- data-variant="ghost"
1248
- data-comment-action="toggle-ai-omission"
1249
- data-comment-id="${escapeHtml(comment.id)}"
1250
- data-omitted-for-ai="${omittedForAi ? "true" : "false"}"
1251
- >${omittedForAi ? "AI Include" : "AI Ignore"}</button>`
1252
- : ""
1253
- }
1254
- </div>
1255
- </div>
1256
- `;
1257
- }
1258
-
1259
- /**
1260
- * Click-to-edit row for AI Plan / Implementation fields and any other
1261
- * long-form ticket field that should render literal read-only text and become
1262
- * a textarea on click. The wiring (event handlers + commit) lives in
1263
- * wireTicketDetailEditors so this helper stays a pure template.
1264
- */
1265
- export function renderInlinePreservedTextField({
1266
- fieldName,
1267
- label,
1268
- value,
1269
- placeholder,
1270
- ticketId,
1271
- artifactPath,
1272
- artifactFieldName,
1273
- artifactLabel,
1274
- }) {
1275
- const text = value || "";
1276
- const hasValue = Boolean(text.trim());
1277
- const artifactPathText = String(artifactPath || "").trim();
1278
- const hasArtifactControl = Boolean(artifactLabel || artifactFieldName);
1279
- const inner = hasValue
1280
- ? renderPreservedText(text)
1281
- : escapeHtml(placeholder || "");
1282
- const placeholderClass = hasValue ? "" : "is-placeholder";
1283
- const artifactButton = hasArtifactControl
1284
- ? `<button
1285
- type="button"
1286
- class="detail-action-btn"
1287
- data-variant="ghost"
1288
- data-open-artifact="${escapeHtml(artifactPathText)}"
1289
- data-artifact-field="${escapeHtml(artifactFieldName || "")}"
1290
- data-artifact-ticket-id="${escapeHtml(ticketId || "")}"
1291
- data-artifact-title="${escapeHtml(label)}"
1292
- >${escapeHtml(artifactLabel || "Open full artifact")}</button>`
1293
- : "";
1294
- return `
1295
- <div class="inline-md-field">
1296
- <div class="inline-md-field-head">
1297
- <span class="inline-md-field-label">${escapeHtml(label)}</span>
1298
- ${artifactButton}
1299
- </div>
1300
- <div
1301
- class="inline-md-field-body preserved-text-body editable-field ${placeholderClass}"
1302
- data-edit-field="${escapeHtml(fieldName)}"
1303
- tabindex="0"
1304
- title="Click to edit"
1305
- role="button"
1306
- aria-label="Edit ${escapeHtml(label)}"
1307
- >${inner}</div>
1308
- </div>
1309
- `;
1310
- }
1311
-
1312
- // Tickets eligible to be linked as "related": everything in the project except
1313
- // the ticket itself, its existing relations, and its parent/child hierarchy.
1314
- function relatedTicketCandidates(relations = [], ticket) {
1315
- const relatedIds = new Set(relations.map((r) => r.other_ticket.id));
1316
- const hierarchyIds = new Set();
1317
- if (ticket?.parent_ticket_id) hierarchyIds.add(ticket.parent_ticket_id);
1318
- if (ticket?.id) {
1319
- for (const t of ticketsForProject()) {
1320
- if (t.parent_ticket_id === ticket.id) hierarchyIds.add(t.id);
1321
- }
1322
- }
1323
- return ticketsForProject().filter(
1324
- (t) =>
1325
- t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id),
1326
- );
1327
- }
1328
-
1329
- function renderRelated(relations = [], ticket) {
1330
- const list = relations.length
1331
- ? `<div class="detail-card-grid">${relations.map((r) => renderRelatedCard(r)).join("")}</div>`
1332
- : "";
1333
-
1334
- const form = `
1335
- <form id="relatedAddForm" class="related-add-row">
1336
- <div class="related-add-input-wrap">
1337
- <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add related ticket…" aria-label="Add related ticket" autocomplete="off" />
1338
- <button type="button" class="related-add-clear" data-related-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1339
- </div>
1340
- <select name="type" class="related-add-type meta-inline" aria-label="Relation type">
1341
- <option value="relates_to">relates to</option>
1342
- <option value="blocks">blocks</option>
1343
- <option value="blocked_by">blocked by</option>
1344
- </select>
1345
- <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
1346
- </form>
1347
- `;
1348
-
1349
- return list + form;
1350
- }
1351
-
1352
- function renderHierarchySection(ticket, context) {
1353
- const isEpic = canonicalTicketType(ticket.type) === "epic";
1354
- if (!isEpic) return "";
1355
-
1356
- const cards = context.child_tickets || [];
1357
- const grid = cards.length
1358
- ? `<div class="detail-card-grid">${cards.map((c) => renderDetailCard(c, { detach: { mode: "child", id: c.id } })).join("")}</div>`
1359
- : "";
1360
-
1361
- const form = `
1362
- <form id="epicChildForm" class="related-add-row epic-child-form">
1363
- <div class="related-add-input-wrap">
1364
- <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add feature to epic…" aria-label="Add feature to epic" autocomplete="off" />
1365
- <button type="button" class="related-add-clear" data-epic-child-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1366
- </div>
1367
- <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
1368
- </form>
1369
- `;
1370
-
1371
- return renderPanel({
1372
- title: "Features inside this Epic",
1373
- body: `<div class="settings-row">${grid}${form}</div>`,
1374
- });
1375
- }
1376
-
1377
- // Epics eligible to become this ticket's parent (any epic but itself).
1378
- function epicCandidates(ticket) {
1379
- return ticketsForProject().filter(
1380
- (t) => t.type === "epic" && t.id !== ticket.id,
1381
- );
1382
- }
1383
-
1384
- // Tickets eligible to be pulled into this epic as children: any non-epic ticket
1385
- // not already parented to it. (A ticket with another parent is reparented.)
1386
- function childCandidates(epic) {
1387
- return ticketsForProject().filter(
1388
- (t) =>
1389
- t.id !== epic.id &&
1390
- canonicalTicketType(t.type) !== "epic" &&
1391
- t.parent_ticket_id !== epic.id,
1392
- );
1393
- }
1394
-
1395
-
1396
- // Board labels not yet applied to this ticket, as {name, color} items.
1397
- function labelCandidates(ticket) {
1398
- const applied = new Set((ticket.labels || []).map((l) => l.name));
1399
- return boardLabelCatalog().filter((l) => !applied.has(l.name));
1400
- }
1401
-
1402
- // A typeahead row for a label: colour swatch + bold name. Mirrors the topbar
1403
- // hit layout (bold lead + trailing badge), adapted for a label's lack of a
1404
- // number / state.
1405
- function renderLabelHitRow(label) {
1406
- return `
1407
- <span class="search-hit-main">
1408
- <span class="typeahead-swatch" style="--label-color: ${escapeHtml(label.color || "var(--muted)")}"></span>
1409
- <strong>${escapeHtml(label.name)}</strong>
1410
- </span>
1411
- `;
1412
- }
1413
-
1414
- function attachLabelTypeahead(input, ticket) {
1415
- if (!input) return;
1416
- attachTypeahead(input, {
1417
- getItems: () => labelCandidates(ticket),
1418
- match: (label, query) => label.name.toLowerCase().includes(query),
1419
- renderItem: renderLabelHitRow,
1420
- valueOf: (label) => label.name,
1421
- emptyText: "No matching labels",
1422
- });
1423
- }
1424
-
1425
- function renderParentEpicSection(ticket, context) {
1426
- if (canonicalTicketType(ticket.type) === "epic") return "";
1427
-
1428
- const body = context.parent_ticket
1429
- ? `<div class="detail-card-grid detail-card-grid--single">${renderDetailCard(context.parent_ticket, { detach: { mode: "parent" } })}</div>`
1430
- : `
1431
- <form id="parentEpicForm" class="related-add-row parent-epic-form">
1432
- <div class="related-add-input-wrap">
1433
- <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Set parent epic…" aria-label="Set parent epic" autocomplete="off" />
1434
- <button type="button" class="related-add-clear" data-parent-epic-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1435
- </div>
1436
- <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Set</button>
1437
- </form>
1438
- `;
1439
-
1440
- return renderPanel({
1441
- title: "Epic",
1442
- body: `<div class="settings-row">${body}</div>`,
1443
- });
1444
- }
1445
-
1446
- export function renderDetailCard(ticket, options = {}) {
1447
- const canonicalType = canonicalTicketType(ticket.type);
1448
- const priorityKey = priorityKeyFor(ticket.priority);
1449
- const stateClass = stateClassFor(ticket);
1450
- const typeText = typeLabel(ticket.type).toUpperCase();
1451
- const eyebrow = options.eyebrow
1452
- ? `<span class="card-eyebrow">${escapeHtml(options.eyebrow)}</span>`
1453
- : "";
1454
- let detachBtn = "";
1455
- if (options.detach) {
1456
- const { mode, id } = options.detach;
1457
- let attr = "";
1458
- let label = "";
1459
- if (mode === "child") {
1460
- attr = `data-detach-child="${escapeHtml(id)}"`;
1461
- label = "Remove from epic";
1462
- } else if (mode === "parent") {
1463
- attr = `data-detach-parent="1"`;
1464
- label = "Remove parent epic";
1465
- } else if (mode === "relation") {
1466
- attr = `data-detach-relation="${escapeHtml(id)}"`;
1467
- label = "Remove relation";
1468
- }
1469
- detachBtn = `<button type="button" class="card-detach-btn" ${attr} aria-label="${label}" title="${label}">${closeIconSvg}</button>`;
1470
- } else if (options.archive) {
1471
- const tid = escapeHtml(options.archive.id);
1472
- const restoreSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>`;
1473
- const trashSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`;
1474
- detachBtn = `
1475
- <div class="card-archive-actions">
1476
- <button type="button" class="card-archive-btn card-archive-btn--restore" data-archive-restore="${tid}" aria-label="Restore to board" title="Restore to board">${restoreSvg}</button>
1477
- <button type="button" class="card-archive-btn card-archive-btn--delete" data-archive-delete="${tid}" aria-label="Delete permanently" title="Delete permanently">${trashSvg}</button>
1478
- </div>
1479
- `;
1480
- }
1481
- const unreadN = unreadCount(ticket);
1482
- const unreadDot =
1483
- unreadN === 0
1484
- ? ""
1485
- : unreadN === 1
1486
- ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
1487
- : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
1488
- return `
1489
- <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
1490
- ${unreadDot}
1491
- ${eyebrow}
1492
- <h3>${escapeHtml(ticket.title)}</h3>
1493
- <div class="card-meta">
1494
- <div class="card-expand-trigger card-expand-trigger--static">
1495
- <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
1496
- <div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
1497
- ${renderTypeIcon(ticket.type)}
1498
- <span class="card-type-label">${escapeHtml(typeText)}</span>
1499
- </div>
1500
- <span class="detail-card-state" data-variant="${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
1501
- ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
1502
- ${detachBtn}
1503
- </div>
1504
- </div>
1505
- </article>
1506
- `;
1507
- }
1508
-
1509
- function renderRelatedCard(relation) {
1510
- const verb = String(relation.type || "").replace(/_/g, " ");
1511
- const direction = relation.direction === "incoming" ? "from" : "to";
1512
- return renderDetailCard(relation.other_ticket, {
1513
- eyebrow: `${verb} ${direction}`,
1514
- detach: { mode: "relation", id: relation.id },
1515
- });
1516
- }