trekoon 0.2.9 → 0.3.1

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 (41) hide show
  1. package/.agents/skills/trekoon/SKILL.md +162 -26
  2. package/README.md +18 -15
  3. package/docs/ai-agents.md +49 -4
  4. package/docs/commands.md +90 -16
  5. package/docs/machine-contracts.md +120 -0
  6. package/docs/plans/r1-unified-skill-rewrite.md +290 -0
  7. package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
  8. package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
  9. package/docs/quickstart.md +41 -12
  10. package/package.json +23 -1
  11. package/src/board/assets/app.js +1 -0
  12. package/src/board/assets/components/EpicRow.js +21 -6
  13. package/src/board/assets/components/EpicsOverview.js +5 -1
  14. package/src/board/assets/components/Notice.js +19 -12
  15. package/src/board/assets/components/Workspace.js +16 -5
  16. package/src/board/assets/components/helpers.js +17 -0
  17. package/src/board/assets/runtime/clipboard.js +34 -0
  18. package/src/board/assets/runtime/delegation.js +33 -0
  19. package/src/board/assets/state/actions.js +68 -0
  20. package/src/board/assets/state/store.js +1 -0
  21. package/src/board/assets/styles/board.css +156 -36
  22. package/src/board/routes.ts +2 -0
  23. package/src/commands/epic.ts +74 -3
  24. package/src/commands/session.ts +7 -75
  25. package/src/commands/subtask.ts +7 -5
  26. package/src/commands/suggest.ts +283 -0
  27. package/src/commands/sync-helpers.ts +75 -0
  28. package/src/commands/task-readiness.ts +8 -20
  29. package/src/commands/task.ts +59 -3
  30. package/src/domain/mutation-service.ts +69 -42
  31. package/src/domain/tracker-domain.ts +151 -22
  32. package/src/domain/types.ts +12 -0
  33. package/src/index.ts +1 -1
  34. package/src/io/output.ts +4 -2
  35. package/src/runtime/cli-shell.ts +26 -3
  36. package/src/runtime/command-types.ts +1 -1
  37. package/src/storage/database.ts +43 -1
  38. package/src/storage/events-retention.ts +57 -8
  39. package/src/storage/migrations.ts +58 -3
  40. package/src/sync/service.ts +101 -24
  41. package/src/sync/types.ts +1 -0
@@ -75,20 +75,19 @@ Current runtime expectations for operators:
75
75
 
76
76
  - the served HTML, styles, and board app files come from the local
77
77
  `.trekoon/board` runtime directory
78
- - the current shell also pulls Vue from `unpkg.com`, Tailwind from
79
- `cdn.tailwindcss.com`, and Google-hosted fonts/icons at page load time
80
- - if those hosts are unavailable, the command still starts the loopback server
81
- and prints the fallback URL, but the shell may not fully hydrate in the
82
- browser until network access is available again
78
+ - all assets are self-hosted: the board ships its own CSS, fonts (Inter,
79
+ Material Symbols), and vanilla JS with no framework or CDN dependencies
80
+ - the board works fully offline once the runtime assets are copied into
81
+ `.trekoon/board`
83
82
 
84
83
  Current layout behavior:
85
84
 
86
85
  - the topbar is a compact navbar with workspace identity, Epics and Board
87
86
  navigation, debounced search, theme toggle, and workspace info
88
- - on wide displays, expect a three-surface workspace: epic switcher sidebar,
89
- task workspace, and task inspector
90
- - on medium and small displays, secondary surfaces collapse into stacked panels
91
- or drawer-style views so the board remains navigable on narrower widths
87
+ - the board toggles between an epics overview and a task workspace view; task
88
+ detail opens as a modal overlay
89
+ - responsive breakpoints adjust kanban column counts and component spacing so
90
+ the board remains navigable on narrower widths
92
91
  - the page scrolls naturally as one document; modal overlays lock body scroll
93
92
  while open
94
93
  - task cards show truncated descriptions; clicking a card opens the task detail
@@ -106,9 +105,8 @@ Verification checklist for operators:
106
105
  the search input while typing.
107
106
  4. Click a task card and confirm the task detail modal opens with the full
108
107
  description, edit form, and subtask list.
109
- 5. On desktop width, confirm the epic switcher sidebar, workspace, and detail
110
- panel can all be visible simultaneously.
111
- 6. Resize to a narrow viewport and confirm the layout stacks cleanly without
108
+ 5. On desktop width, confirm the kanban board shows multiple columns.
109
+ 6. Resize to a narrow viewport and confirm columns reflow cleanly without
112
110
  horizontal overflow.
113
111
  7. Close modals and confirm you return to the previous board context.
114
112
 
@@ -200,6 +198,37 @@ Rules:
200
198
  - If any epic/task descendant is blocked by an unresolved external dependency,
201
199
  the whole cascade fails with no partial writes
202
200
 
201
+ ## Check progress and get suggestions
202
+
203
+ After creating work, use `epic progress` to see status counts and the next ready
204
+ candidate:
205
+
206
+ ```bash
207
+ trekoon epic progress <epic-id>
208
+ ```
209
+
210
+ Use `suggest` for priority-ranked next-action recommendations:
211
+
212
+ ```bash
213
+ trekoon suggest
214
+ trekoon suggest --epic <epic-id>
215
+ ```
216
+
217
+ ## Status machine
218
+
219
+ Trekoon enforces valid status transitions. The canonical statuses are `todo`,
220
+ `in_progress`, `done`, and `blocked`. Direct jumps like `todo → done` are
221
+ rejected — use `task done` which auto-transitions through `in_progress`.
222
+
223
+ Valid transitions:
224
+
225
+ | From | Allowed targets |
226
+ | --- | --- |
227
+ | `todo` | `in_progress`, `blocked` |
228
+ | `in_progress` | `done`, `blocked` |
229
+ | `blocked` | `in_progress`, `todo` |
230
+ | `done` | `in_progress` |
231
+
203
232
  ## What to read next
204
233
 
205
234
  - [Command reference](commands.md)
package/package.json CHANGED
@@ -1,8 +1,30 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "AI-first local issue tracker CLI.",
5
+ "keywords": [
6
+ "ai",
7
+ "agent",
8
+ "skill",
9
+ "cli",
10
+ "board",
11
+ "tracker",
12
+ "agent-tracker",
13
+ "issue-tracker",
14
+ "task-tracker",
15
+ "local-first",
16
+ "planning",
17
+ "worktree"
18
+ ],
5
19
  "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/KristjanPikhof/Trekoon.git"
23
+ },
24
+ "homepage": "https://github.com/KristjanPikhof/Trekoon#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/KristjanPikhof/Trekoon/issues"
27
+ },
6
28
  "type": "module",
7
29
  "bin": {
8
30
  "trekoon": "./bin/trekoon"
@@ -525,6 +525,7 @@ export async function bootLegacyBoard(options = {}) {
525
525
  showBoard: () => actions.showBoard(),
526
526
  scrollToDetail: () => document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: getScrollBehavior() }),
527
527
  setView: (view) => actions.setView(view),
528
+ copyEpicId: (id) => actions.copyEpicId(id),
528
529
  toggleTheme: () => actions.toggleTheme(),
529
530
  toggleNotesPanel: () => actions.toggleNotesPanel(),
530
531
  confirmDelete: () => {
@@ -2,6 +2,8 @@ import {
2
2
  escapeHtml,
3
3
  formatDate,
4
4
  neutralChipClasses,
5
+ renderCheckIcon,
6
+ renderCopyIcon,
5
7
  renderIcon,
6
8
  renderStatusBadge,
7
9
  } from "./helpers.js";
@@ -12,34 +14,47 @@ import {
12
14
  * @param {object} props
13
15
  * @param {object} props.epic
14
16
  * @param {boolean} [props.selected]
17
+ * @param {boolean} [props.copied]
15
18
  * @returns {string}
16
19
  */
17
20
  export function renderEpicRow(props) {
18
- const { epic, selected = false } = props;
21
+ const { epic, selected = false, copied = false } = props;
19
22
 
20
23
  const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
21
24
  const counts = epic.counts || { blocked: 0, done: 0, in_progress: 0 };
22
25
  const statusLabel = String(epic.status ?? "todo").replace(/_/g, " ");
23
26
  const openLabel = `Open epic ${epic.title}`;
24
27
  const tooltip = `${openLabel}. ${totalTasks} task${totalTasks === 1 ? "" : "s"}.`;
28
+ const copyLabel = `Copy epic UUID for ${epic.title}`;
29
+ const copyTooltip = `Copy epic UUID ${epic.id}.`;
25
30
  const descriptionMarkup = epic.description?.trim()
26
31
  ? `<p class="board-epic-row__description text-sm text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(epic.description.trim())}</p>`
27
32
  : "";
28
33
 
29
34
  return `
30
- <button
31
- type="button"
35
+ <div
32
36
  class="board-epic-row ${selected ? "board-epic-row--selected" : ""}"
37
+ role="button"
38
+ tabindex="0"
33
39
  aria-current="${selected}"
34
40
  aria-label="${escapeHtml(`${openLabel}. ${totalTasks} tasks. Status ${statusLabel}.`)}"
35
41
  title="${escapeHtml(tooltip)}"
36
42
  data-open-epic="${escapeHtml(epic.id)}"
37
43
  >
38
44
  <span class="board-epic-row__summary">
39
- <span class="board-epic-row__title-row">
45
+ <span class="board-epic-row__meta-row">
40
46
  <span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
41
- <strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
47
+ <button
48
+ type="button"
49
+ class="board-copy-btn board-copy-btn--icon board-copy-btn--epic-row ${copied ? "board-copy-btn--active" : ""}"
50
+ data-copy-epic-id="${escapeHtml(epic.id)}"
51
+ aria-label="${escapeHtml(copied ? `Copied epic UUID for ${epic.title}` : copyLabel)}"
52
+ title="${escapeHtml(copied ? "Epic UUID copied." : copyTooltip)}"
53
+ >
54
+ ${copied ? renderCheckIcon("board-inline-icon--sm") : renderCopyIcon("board-inline-icon--sm")}
55
+ </button>
42
56
  </span>
57
+ <strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
43
58
  ${descriptionMarkup}
44
59
  </span>
45
60
  <span class="board-epic-row__status">${renderStatusBadge(epic.status ?? "todo")}</span>
@@ -59,6 +74,6 @@ export function renderEpicRow(props) {
59
74
  ${renderIcon("chevron_right", "text-[16px]")}
60
75
  </span>
61
76
  </span>
62
- </button>
77
+ </div>
63
78
  `;
64
79
  }
@@ -43,7 +43,11 @@ function render(props) {
43
43
  <div class="board-table__rows board-table__rows--epics">
44
44
  ${visibleEpics.length === 0
45
45
  ? renderEmptyState("No matching epics", "Try a different search or publish more work to the board.", "/")
46
- : visibleEpics.map((epic) => renderEpicRow({ epic, selected: selectedEpicId === epic.id })).join("")}
46
+ : visibleEpics.map((epic) => renderEpicRow({
47
+ epic,
48
+ selected: selectedEpicId === epic.id,
49
+ copied: store.copyFeedback?.epicId === epic.id,
50
+ })).join("")}
47
51
  </div>
48
52
  </div>
49
53
  </section>
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  escapeHtml,
3
- panelClasses,
3
+ renderCheckIcon,
4
4
  renderIcon,
5
- sectionLabelClasses,
6
5
  } from "./helpers.js";
7
6
 
8
7
  /**
@@ -27,7 +26,7 @@ export function createNotice() {
27
26
  },
28
27
 
29
28
  /**
30
- * @param {{ notice: { type: string, message: string } | null, onDismiss?: () => void }} props
29
+ * @param {{ notice: { type: string, message: string, title?: string } | null, onDismiss?: () => void }} props
31
30
  */
32
31
  update(props) {
33
32
  if (!container) return;
@@ -47,16 +46,24 @@ export function createNotice() {
47
46
  return;
48
47
  }
49
48
 
49
+ const noticeTitle = typeof notice.title === "string" && notice.title.trim().length > 0
50
+ ? notice.title.trim()
51
+ : notice.type === "error"
52
+ ? "Action blocked"
53
+ : "Saved";
54
+
50
55
  container.innerHTML = `
51
- <section class="${panelClasses("mb-4 flex items-start gap-3 p-4 sm:p-5")}" role="${notice.type === "error" ? "alert" : "status"}" aria-live="${notice.type === "error" ? "assertive" : "polite"}" aria-atomic="true">
52
- <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${notice.type === "error" ? "bg-red-500/10 text-red-300 ring-1 ring-red-500/20" : "bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/20"}">
53
- ${renderIcon(notice.type === "error" ? "warning" : "check_circle", "text-[20px]")}
54
- </div>
55
- <div class="min-w-0">
56
- <p class="${sectionLabelClasses()}" id="board-notice-title">${notice.type === "error" ? "Action blocked" : "Saved"}</p>
57
- <p class="mt-1 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(notice.message)}</p>
58
- </div>
59
- </section>
56
+ <div class="board-toast-region" role="presentation">
57
+ <section class="board-toast board-toast--${notice.type === "error" ? "error" : "success"}" role="${notice.type === "error" ? "alert" : "status"}" aria-live="${notice.type === "error" ? "assertive" : "polite"}" aria-atomic="true">
58
+ <div class="board-toast__icon ${notice.type === "error" ? "board-toast__icon--error" : "board-toast__icon--success"}">
59
+ ${notice.type === "error" ? renderIcon("warning") : renderCheckIcon()}
60
+ </div>
61
+ <div class="board-toast__content">
62
+ <p class="board-toast__title" id="board-notice-title">${escapeHtml(noticeTitle)}</p>
63
+ <p class="board-toast__message">${escapeHtml(notice.message)}</p>
64
+ </div>
65
+ </section>
66
+ </div>
60
67
  `;
61
68
  lastNotice = { type: notice.type, message: notice.message };
62
69
 
@@ -8,6 +8,8 @@ import {
8
8
  neutralChipClasses,
9
9
  panelClasses,
10
10
  readStatusLabel,
11
+ renderCheckIcon,
12
+ renderCopyIcon,
11
13
  renderEpicCountSummary,
12
14
  renderEmptyState,
13
15
  renderIcon,
@@ -62,6 +64,9 @@ function renderWorkspaceHeader(props) {
62
64
  const epicSelectTooltip = "Switch the workspace to a different epic.";
63
65
  const bulkStatusTooltip = "Set the same status for every task in this epic.";
64
66
  const notesTooltip = "Show or hide this epic's description.";
67
+ const copyTooltip = "Copy this epic's UUID.";
68
+ const copyLabel = `Copy epic UUID for ${selectedEpic.title}`;
69
+ const isCopied = store.copyFeedback?.epicId === selectedEpic.id;
65
70
  const notesPanelId = `board-notes-panel-${selectedEpic.id}`;
66
71
  const orderedEpics = orderEpicsNewestFirst(snapshotEpics);
67
72
 
@@ -104,12 +109,17 @@ function renderWorkspaceHeader(props) {
104
109
 
105
110
  <div class="board-wh__row-2">
106
111
  <div class="board-wh__meta">
112
+ <span class="${neutralChipClasses()}">${escapeHtml(selectedEpic.id)}</span>
107
113
  ${renderEpicCountSummary(selectedEpic)}
108
114
  <span class="${neutralChipClasses()}">${visibleTasks.length} visible</span>
109
115
  ${store.isMutating ? `<span class="${neutralChipClasses()}">Saving\u2026</span>` : ""}
110
116
  </div>
111
117
  <div class="board-wh__actions">
112
118
  <div class="board-wh__action-group">
119
+ <button type="button" class="board-copy-btn ${isCopied ? "board-copy-btn--active" : ""}" data-copy-epic-id="${escapeHtml(selectedEpic.id)}" aria-label="${escapeHtml(isCopied ? `Copied epic UUID for ${selectedEpic.title}` : copyLabel)}" title="${escapeHtml(isCopied ? "Epic UUID copied." : copyTooltip)}">
120
+ ${isCopied ? renderCheckIcon() : renderCopyIcon()}
121
+ <span class="board-copy-btn__label">${isCopied ? "Copied" : "Copy ID"}</span>
122
+ </button>
113
123
  ${description ? `
114
124
  <button type="button" class="board-wh__notes-btn ${store.notesPanelOpen ? "board-wh__notes-btn--active" : ""}" data-toggle-notes aria-label="Toggle epic notes" aria-expanded="${store.notesPanelOpen}" aria-controls="${escapeHtml(notesPanelId)}" title="${escapeHtml(notesTooltip)}">
115
125
  ${renderIcon("subject", "text-[16px]")}
@@ -255,11 +265,12 @@ function render(props) {
255
265
  const headerMarkup = renderWorkspaceHeader({
256
266
  selectedEpic,
257
267
  snapshotEpics,
258
- store: {
259
- notesPanelOpen: store.notesPanelOpen,
260
- isMutating: store.isMutating,
261
- selectedEpicId: selectedEpic.id,
262
- view: store.view,
268
+ store: {
269
+ copyFeedback: store.copyFeedback,
270
+ notesPanelOpen: store.notesPanelOpen,
271
+ isMutating: store.isMutating,
272
+ selectedEpicId: selectedEpic.id,
273
+ view: store.view,
263
274
  viewModes,
264
275
  },
265
276
  visibleTasks,
@@ -88,6 +88,23 @@ export function renderIcon(name, className = "") {
88
88
  return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
89
89
  }
90
90
 
91
+ export function renderCopyIcon(className = "") {
92
+ return `
93
+ <svg class="${cx("board-inline-icon", className)}" aria-hidden="true" viewBox="0 0 16 16" fill="none">
94
+ <rect x="5" y="2.75" width="7" height="9" rx="1.5" stroke="currentColor" stroke-width="1.35"></rect>
95
+ <path d="M4 5.75H3.5C2.67157 5.75 2 6.42157 2 7.25V12C2 12.8284 2.67157 13.5 3.5 13.5H8.25C9.07843 13.5 9.75 12.8284 9.75 12V11.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"></path>
96
+ </svg>
97
+ `;
98
+ }
99
+
100
+ export function renderCheckIcon(className = "") {
101
+ return `
102
+ <svg class="${cx("board-inline-icon", className)}" aria-hidden="true" viewBox="0 0 16 16" fill="none">
103
+ <path d="M3.5 8.4L6.4 11.1L12.5 4.9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
104
+ </svg>
105
+ `;
106
+ }
107
+
91
108
  export function readStatusLabel(rawStatus) {
92
109
  if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
93
110
  return "Unknown";
@@ -0,0 +1,34 @@
1
+ export async function copyTextToClipboard(value) {
2
+ const text = typeof value === "string" ? value : String(value ?? "");
3
+
4
+ if (!text) {
5
+ throw new Error("Clipboard text is empty.");
6
+ }
7
+
8
+ if (navigator?.clipboard?.writeText) {
9
+ await navigator.clipboard.writeText(text);
10
+ return;
11
+ }
12
+
13
+ const textarea = document.createElement("textarea");
14
+ textarea.value = text;
15
+ textarea.setAttribute("readonly", "true");
16
+ textarea.setAttribute("aria-hidden", "true");
17
+ textarea.style.position = "fixed";
18
+ textarea.style.top = "0";
19
+ textarea.style.left = "0";
20
+ textarea.style.opacity = "0";
21
+ textarea.style.pointerEvents = "none";
22
+
23
+ document.body.append(textarea);
24
+ textarea.focus({ preventScroll: true });
25
+ textarea.select();
26
+ textarea.setSelectionRange(0, text.length);
27
+
28
+ const didCopy = document.execCommand("copy");
29
+ textarea.remove();
30
+
31
+ if (!didCopy) {
32
+ throw new Error("Clipboard API unavailable.");
33
+ }
34
+ }
@@ -43,6 +43,12 @@ export function createDelegation(rootElement, actions) {
43
43
  return;
44
44
  }
45
45
 
46
+ const copyEpicIdEl = target.closest("[data-copy-epic-id]");
47
+ if (copyEpicIdEl) {
48
+ actions.copyEpicId(copyEpicIdEl.dataset.copyEpicId || null);
49
+ return;
50
+ }
51
+
46
52
  // -- Backdrop-style close handlers ----------------------------------------
47
53
  // Only close when the click lands directly on the backdrop element itself,
48
54
  // not on any child content rendered inside the overlay.
@@ -242,6 +248,31 @@ export function createDelegation(rootElement, actions) {
242
248
  actions.handleKeydown(event);
243
249
  }
244
250
 
251
+ function handleDelegatedKeydown(event) {
252
+ if (event.defaultPrevented) {
253
+ return;
254
+ }
255
+
256
+ const target = event.target instanceof Element ? event.target : null;
257
+ if (!target) {
258
+ return;
259
+ }
260
+
261
+ if (target.closest("[data-copy-epic-id]")) {
262
+ return;
263
+ }
264
+
265
+ const openEpicEl = target.closest("[data-open-epic]");
266
+ if (!openEpicEl) {
267
+ return;
268
+ }
269
+
270
+ if (event.key === "Enter" || event.key === " ") {
271
+ event.preventDefault();
272
+ actions.openEpic(openEpicEl.dataset.openEpic || null);
273
+ }
274
+ }
275
+
245
276
  // ---------------------------------------------------------------------------
246
277
  // Drag-and-drop delegation
247
278
  // ---------------------------------------------------------------------------
@@ -291,6 +322,7 @@ export function createDelegation(rootElement, actions) {
291
322
  rootElement.addEventListener("dragstart", handleDragstart);
292
323
  rootElement.addEventListener("dragover", handleDragover);
293
324
  rootElement.addEventListener("drop", handleDrop);
325
+ rootElement.addEventListener("keydown", handleDelegatedKeydown);
294
326
  window.addEventListener("keydown", handleKeydown);
295
327
 
296
328
  // ---------------------------------------------------------------------------
@@ -304,6 +336,7 @@ export function createDelegation(rootElement, actions) {
304
336
  rootElement.removeEventListener("dragstart", handleDragstart);
305
337
  rootElement.removeEventListener("dragover", handleDragover);
306
338
  rootElement.removeEventListener("drop", handleDrop);
339
+ rootElement.removeEventListener("keydown", handleDelegatedKeydown);
307
340
  window.removeEventListener("keydown", handleKeydown);
308
341
  };
309
342
  }
@@ -1,3 +1,4 @@
1
+ import { copyTextToClipboard } from "../runtime/clipboard.js";
1
2
  import { orderEpicsNewestFirst } from "./store.js";
2
3
 
3
4
  function cloneSnapshot(snapshot) {
@@ -156,6 +157,7 @@ export function createBoardActions(options) {
156
157
 
157
158
  let searchTimer = null;
158
159
  let pendingSearchValue = null;
160
+ let copyFeedbackTimer = null;
159
161
 
160
162
  const syncSearchInputToState = () => {
161
163
  const input = document.querySelector("#board-search-input");
@@ -186,6 +188,38 @@ export function createBoardActions(options) {
186
188
 
187
189
  const shouldRefocusSearchInput = () => document.activeElement?.id === "board-search-input";
188
190
 
191
+ const clearCopyFeedback = ({ rerenderBoard = true } = {}) => {
192
+ if (copyFeedbackTimer !== null) {
193
+ clearTimeout(copyFeedbackTimer);
194
+ copyFeedbackTimer = null;
195
+ }
196
+
197
+ if (!store.copyFeedback) {
198
+ return;
199
+ }
200
+
201
+ store.copyFeedback = null;
202
+ if (rerenderBoard) {
203
+ rerender();
204
+ }
205
+ };
206
+
207
+ const scheduleCopyFeedbackClear = (epicId) => {
208
+ if (copyFeedbackTimer !== null) {
209
+ clearTimeout(copyFeedbackTimer);
210
+ }
211
+
212
+ copyFeedbackTimer = setTimeout(() => {
213
+ copyFeedbackTimer = null;
214
+ if (store.copyFeedback?.epicId !== epicId) {
215
+ return;
216
+ }
217
+
218
+ store.copyFeedback = null;
219
+ rerender();
220
+ }, 1800);
221
+ };
222
+
189
223
  const commitSearch = (nextSearch, options = {}) => {
190
224
  const { focusInput = false } = options;
191
225
  cancelPendingSearch({ syncInput: false });
@@ -266,6 +300,37 @@ export function createBoardActions(options) {
266
300
  setView(view) {
267
301
  transition({ view });
268
302
  },
303
+ async copyEpicId(epicId) {
304
+ const normalizedEpicId = typeof epicId === "string" ? epicId.trim() : "";
305
+
306
+ if (!normalizedEpicId) {
307
+ clearCopyFeedback({ rerenderBoard: false });
308
+ store.notice = {
309
+ type: "error",
310
+ title: "Copy failed",
311
+ message: "Epic UUID is unavailable.",
312
+ };
313
+ rerender();
314
+ return;
315
+ }
316
+
317
+ try {
318
+ await copyTextToClipboard(normalizedEpicId);
319
+ store.copyFeedback = {
320
+ epicId: normalizedEpicId,
321
+ };
322
+ scheduleCopyFeedbackClear(normalizedEpicId);
323
+ } catch {
324
+ clearCopyFeedback({ rerenderBoard: false });
325
+ store.notice = {
326
+ type: "error",
327
+ title: "Copy failed",
328
+ message: "Could not copy the epic UUID.",
329
+ };
330
+ }
331
+
332
+ rerender();
333
+ },
269
334
  selectTask(taskId) {
270
335
  const task = getTaskById(taskId);
271
336
  if (!task) {
@@ -409,6 +474,9 @@ export function createBoardActions(options) {
409
474
  } else if (boardState.screen === "tasks") {
410
475
  event.preventDefault();
411
476
  this.showEpics();
477
+ } else if (store.copyFeedback) {
478
+ event.preventDefault();
479
+ clearCopyFeedback();
412
480
  } else if (store.notice) {
413
481
  event.preventDefault();
414
482
  store.notice = null;
@@ -283,6 +283,7 @@ export function createStore(initialSnapshot, options = {}) {
283
283
  selectedSubtaskId: null,
284
284
  theme: readThemePreference(),
285
285
  focusedEpicIndex: 0,
286
+ copyFeedback: null,
286
287
  notice: null,
287
288
  isMutating: false,
288
289
  notesPanelOpen: storedState.notesPanelOpen === true,