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.
- package/.agents/skills/trekoon/SKILL.md +162 -26
- package/README.md +18 -15
- package/docs/ai-agents.md +49 -4
- package/docs/commands.md +90 -16
- package/docs/machine-contracts.md +120 -0
- package/docs/plans/r1-unified-skill-rewrite.md +290 -0
- package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
- package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
- package/docs/quickstart.md +41 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +1 -0
- package/src/board/assets/components/EpicRow.js +21 -6
- package/src/board/assets/components/EpicsOverview.js +5 -1
- package/src/board/assets/components/Notice.js +19 -12
- package/src/board/assets/components/Workspace.js +16 -5
- package/src/board/assets/components/helpers.js +17 -0
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +33 -0
- package/src/board/assets/state/actions.js +68 -0
- package/src/board/assets/state/store.js +1 -0
- package/src/board/assets/styles/board.css +156 -36
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/subtask.ts +7 -5
- package/src/commands/suggest.ts +283 -0
- package/src/commands/sync-helpers.ts +75 -0
- package/src/commands/task-readiness.ts +8 -20
- package/src/commands/task.ts +59 -3
- package/src/domain/mutation-service.ts +69 -42
- package/src/domain/tracker-domain.ts +151 -22
- package/src/domain/types.ts +12 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +4 -2
- package/src/runtime/cli-shell.ts +26 -3
- package/src/runtime/command-types.ts +1 -1
- package/src/storage/database.ts +43 -1
- package/src/storage/events-retention.ts +57 -8
- package/src/storage/migrations.ts +58 -3
- package/src/sync/service.ts +101 -24
- package/src/sync/types.ts +1 -0
package/docs/quickstart.md
CHANGED
|
@@ -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
|
-
-
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
|
|
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
|
-
-
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
|
|
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
|
|
110
|
-
|
|
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.
|
|
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"
|
package/src/board/assets/app.js
CHANGED
|
@@ -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
|
-
<
|
|
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-
|
|
45
|
+
<span class="board-epic-row__meta-row">
|
|
40
46
|
<span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
|
|
41
|
-
<
|
|
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
|
-
</
|
|
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({
|
|
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
|
-
|
|
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
|
-
<
|
|
52
|
-
<
|
|
53
|
-
${
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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,
|