trekoon 0.4.1 → 0.4.3
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 +97 -765
- package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
- package/.agents/skills/trekoon/reference/execution.md +188 -159
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +213 -213
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +82 -0
- package/README.md +29 -8
- package/docs/ai-agents.md +65 -6
- package/docs/commands.md +149 -5
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +55 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +47 -13
- package/src/board/assets/components/Component.js +20 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +45 -4
- package/src/board/assets/state/api.js +304 -17
- package/src/board/assets/state/store.js +82 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +81 -0
- package/src/board/routes.ts +430 -40
- package/src/board/server.ts +86 -10
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +313 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +75 -10
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/skills.ts +17 -5
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +842 -187
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +167 -693
- package/src/domain/types.ts +56 -2
- package/src/export/render-markdown.ts +1 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +700 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +268 -4
- package/src/storage/migrations.ts +441 -22
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +679 -156
package/src/board/assets/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createBoardActions } from "./state/actions.js";
|
|
2
|
-
import { createApi } from "./state/api.js";
|
|
2
|
+
import { createApi, subscribeSnapshotStream } from "./state/api.js";
|
|
3
3
|
import { applyTheme, createStore, readThemePreference } from "./state/store.js";
|
|
4
4
|
import { normalizeSnapshot, normalizeStatus } from "./state/utils.js";
|
|
5
5
|
import { syncUrlHash } from "./state/url.js";
|
|
6
6
|
import { createDelegation } from "./runtime/delegation.js";
|
|
7
|
+
import { createOverlayFocusTrap } from "./runtime/focus-trap.js";
|
|
7
8
|
import { createTopBar } from "./components/TopBar.js";
|
|
8
9
|
import { createWorkspace } from "./components/Workspace.js";
|
|
9
10
|
import { createTaskModal } from "./components/TaskModal.js";
|
|
@@ -132,15 +133,16 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
132
133
|
const runtimeSession = resolveRuntimeSession();
|
|
133
134
|
if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
|
|
134
135
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
|
|
142
|
-
snapshotPayload = payload?.data?.snapshot ?? {};
|
|
136
|
+
// Always fetch snapshot client-side. The bootstrap script in index.html
|
|
137
|
+
// only carries the auth token; the snapshot is fetched via /api/snapshot
|
|
138
|
+
// so index.html stays small and never carries board data in its HTML body.
|
|
139
|
+
const response = await fetch("/api/snapshot");
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(`Board snapshot request failed with status ${response.status}`);
|
|
143
142
|
}
|
|
143
|
+
const payload = await response.json();
|
|
144
|
+
if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
|
|
145
|
+
const snapshotPayload = payload?.data?.snapshot ?? {};
|
|
144
146
|
|
|
145
147
|
const snapshot = normalizeSnapshot(snapshotPayload);
|
|
146
148
|
|
|
@@ -258,6 +260,12 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
258
260
|
overlay.focus({ preventScroll: true });
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
const overlayFocusTrap = createOverlayFocusTrap({
|
|
264
|
+
doc: document,
|
|
265
|
+
onTabKey: (event) => trapOverlayFocus(event),
|
|
266
|
+
onFocusIn: (event) => containOverlayFocus(event),
|
|
267
|
+
});
|
|
268
|
+
|
|
261
269
|
function syncOverlayEnvironment() {
|
|
262
270
|
const hadOverlay = activeOverlay instanceof HTMLElement;
|
|
263
271
|
const nextOverlay = getActiveOverlayElement();
|
|
@@ -271,6 +279,10 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
271
279
|
lockBackgroundScroll();
|
|
272
280
|
setBackgroundInert(true);
|
|
273
281
|
}
|
|
282
|
+
// Attach the document-level focus trap only while an overlay is actually
|
|
283
|
+
// open. This avoids the microtask window where Tab keydowns get swallowed
|
|
284
|
+
// by a stale handler with no overlay to confine to.
|
|
285
|
+
overlayFocusTrap.attach();
|
|
274
286
|
queueMicrotask(() => focusOverlay(nextOverlay));
|
|
275
287
|
return;
|
|
276
288
|
}
|
|
@@ -280,6 +292,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
280
292
|
unlockBackgroundScroll();
|
|
281
293
|
setBackgroundInert(false);
|
|
282
294
|
}
|
|
295
|
+
overlayFocusTrap.detach();
|
|
283
296
|
queueMicrotask(() => restoreOverlayFocus());
|
|
284
297
|
}
|
|
285
298
|
|
|
@@ -355,8 +368,9 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
355
368
|
return true;
|
|
356
369
|
}
|
|
357
370
|
|
|
358
|
-
|
|
359
|
-
|
|
371
|
+
// Focus-trap listeners are attached lazily by syncOverlayEnvironment when an
|
|
372
|
+
// overlay actually opens (and detached when it closes), so plain Tab outside
|
|
373
|
+
// any overlay reaches normal browser flow.
|
|
360
374
|
|
|
361
375
|
// Render cycle
|
|
362
376
|
function rerender() {
|
|
@@ -366,6 +380,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
366
380
|
const selectedTask = boardState.selectedTask;
|
|
367
381
|
const selectedSubtask = boardState.selectedSubtask;
|
|
368
382
|
const taskModalOpen = Boolean(boardState.taskModalOpen && selectedTask);
|
|
383
|
+
const subtaskModalOpen = Boolean(boardState.subtaskModalOpen && selectedSubtask);
|
|
369
384
|
const currentNav = taskModalOpen ? "detail" : screen === "tasks" ? "board" : "epics";
|
|
370
385
|
|
|
371
386
|
// Layout toggles
|
|
@@ -386,7 +401,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
386
401
|
taskModal.update(null);
|
|
387
402
|
}
|
|
388
403
|
|
|
389
|
-
if (!showTasks || !taskModalOpen || !
|
|
404
|
+
if (!showTasks || !taskModalOpen || !subtaskModalOpen) {
|
|
390
405
|
subtaskModal.update(null);
|
|
391
406
|
}
|
|
392
407
|
|
|
@@ -443,7 +458,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
443
458
|
});
|
|
444
459
|
}
|
|
445
460
|
|
|
446
|
-
subtaskModal.update(showTasks && taskModalOpen &&
|
|
461
|
+
subtaskModal.update(showTasks && taskModalOpen && subtaskModalOpen ? {
|
|
447
462
|
subtask: selectedSubtask,
|
|
448
463
|
isMutating: store.isMutating,
|
|
449
464
|
} : null);
|
|
@@ -453,6 +468,24 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
453
468
|
|
|
454
469
|
const api = createApi(model, { sessionToken: runtimeSession.token, rerender });
|
|
455
470
|
|
|
471
|
+
// Subscribe to /api/snapshot/stream so external CLI writes (picked up by
|
|
472
|
+
// the WAL watcher) and concurrent-tab mutations are reflected in this
|
|
473
|
+
// board within ~1s without manual refresh. applySnapshotDelta is idempotent
|
|
474
|
+
// so re-receiving deltas already applied via mutation responses is safe.
|
|
475
|
+
// Capture the handle so we can tear it down on page unload (and so tests
|
|
476
|
+
// and future teardown paths can dispose the EventSource).
|
|
477
|
+
const snapshotSubscription = subscribeSnapshotStream(model, {
|
|
478
|
+
sessionToken: runtimeSession.token,
|
|
479
|
+
rerender,
|
|
480
|
+
});
|
|
481
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
482
|
+
const disposeSnapshotSubscription = () => {
|
|
483
|
+
snapshotSubscription.dispose();
|
|
484
|
+
};
|
|
485
|
+
window.addEventListener("beforeunload", disposeSnapshotSubscription, { once: true });
|
|
486
|
+
window.addEventListener("pagehide", disposeSnapshotSubscription, { once: true });
|
|
487
|
+
}
|
|
488
|
+
|
|
456
489
|
// Actions for delegation
|
|
457
490
|
const actions = createBoardActions({
|
|
458
491
|
model,
|
|
@@ -551,6 +584,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
551
584
|
addDependency: (src, data) => actions.addDependency(src, data),
|
|
552
585
|
dropTaskStatus: (id, status) => actions.dropTaskStatus(id, status),
|
|
553
586
|
getTaskStatus: (id) => actions.getTaskStatus(id),
|
|
587
|
+
setDragFeedback: (feedback) => actions.setDragFeedback(feedback),
|
|
554
588
|
changeEpicStatus: (epicId, status) => actions.changeEpicStatus(epicId, status),
|
|
555
589
|
bulkSetStatus: (epicId, status) => actions.bulkSetStatus(epicId, status),
|
|
556
590
|
toggleEpicStatusFilter: (status) => actions.toggleEpicStatusFilter(status),
|
|
@@ -80,11 +80,18 @@ function getNamespacedFormIdentity(form) {
|
|
|
80
80
|
return "anonymous-form";
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function getManagedControls(root) {
|
|
83
|
+
function getManagedControls(root, cache) {
|
|
84
|
+
if (cache) {
|
|
85
|
+
const cached = cache.get(root);
|
|
86
|
+
if (cached) return cached;
|
|
87
|
+
const controls = Array.from(root.querySelectorAll("input, textarea, select"));
|
|
88
|
+
cache.set(root, controls);
|
|
89
|
+
return controls;
|
|
90
|
+
}
|
|
84
91
|
return Array.from(root.querySelectorAll("input, textarea, select"));
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
function getControlIdentity(el, form) {
|
|
94
|
+
function getControlIdentity(el, form, cache) {
|
|
88
95
|
const controlKey = el.getAttribute("data-control-id");
|
|
89
96
|
if (controlKey) {
|
|
90
97
|
return `control:${controlKey}`;
|
|
@@ -98,12 +105,12 @@ function getControlIdentity(el, form) {
|
|
|
98
105
|
if (name) {
|
|
99
106
|
const tagName = el.tagName.toLowerCase();
|
|
100
107
|
const type = tagName === "input" ? (el.getAttribute("type") ?? "text") : tagName;
|
|
101
|
-
const peers = getManagedControls(form).filter((candidate) => candidate.getAttribute("name") === name);
|
|
108
|
+
const peers = getManagedControls(form, cache).filter((candidate) => candidate.getAttribute("name") === name);
|
|
102
109
|
const index = peers.indexOf(el);
|
|
103
110
|
return `name:${name}:${type}:${index}`;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
const index = getManagedControls(form).indexOf(el);
|
|
113
|
+
const index = getManagedControls(form, cache).indexOf(el);
|
|
107
114
|
return `index:${index}:${el.tagName.toLowerCase()}`;
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -143,7 +150,11 @@ function restoreSelection(el, selectionStart, selectionEnd) {
|
|
|
143
150
|
*/
|
|
144
151
|
export function preserveFormState(container, writeFn, options = {}) {
|
|
145
152
|
const resetFormIds = new Set(options.resetFormIds ?? []);
|
|
146
|
-
|
|
153
|
+
|
|
154
|
+
// Per-form cache for getManagedControls: avoids the O(n^2) re-query that
|
|
155
|
+
// occurs when many controls share the same form root.
|
|
156
|
+
const controlCache = new Map();
|
|
157
|
+
const inputs = getManagedControls(container, controlCache);
|
|
147
158
|
|
|
148
159
|
const activeElement = document.activeElement;
|
|
149
160
|
let focusedIdentity = null;
|
|
@@ -151,7 +162,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
151
162
|
const savedStates = inputs.map((el) => {
|
|
152
163
|
const form = getFormRoot(el);
|
|
153
164
|
const formId = getNamespacedFormIdentity(form);
|
|
154
|
-
const controlId = form ? getControlIdentity(el, form) : null;
|
|
165
|
+
const controlId = form ? getControlIdentity(el, form, controlCache) : null;
|
|
155
166
|
const identity = controlId ? { formId, controlId } : null;
|
|
156
167
|
|
|
157
168
|
if (activeElement === el) {
|
|
@@ -169,6 +180,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
169
180
|
}).filter(s => s.identity);
|
|
170
181
|
|
|
171
182
|
writeFn();
|
|
183
|
+
controlCache.clear();
|
|
172
184
|
|
|
173
185
|
const formsByIdentity = new Map(
|
|
174
186
|
Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
|
|
@@ -183,7 +195,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
183
195
|
continue;
|
|
184
196
|
}
|
|
185
197
|
const form = formsByIdentity.get(formId) ?? container;
|
|
186
|
-
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
198
|
+
const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
|
|
187
199
|
if (restored && restored.value !== state.value) {
|
|
188
200
|
restored.value = state.value;
|
|
189
201
|
}
|
|
@@ -199,7 +211,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
199
211
|
return;
|
|
200
212
|
}
|
|
201
213
|
const form = formsByIdentity.get(formId) ?? container;
|
|
202
|
-
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
214
|
+
const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
|
|
203
215
|
if (restored) {
|
|
204
216
|
restored.focus({ preventScroll: true });
|
|
205
217
|
const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
|
|
@@ -166,7 +166,7 @@ function renderWorkspaceHeader(props) {
|
|
|
166
166
|
// ---------------------------------------------------------------------------
|
|
167
167
|
|
|
168
168
|
function renderKanbanColumns(props) {
|
|
169
|
-
const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
|
|
169
|
+
const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter, dragFeedback } = props;
|
|
170
170
|
const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
171
171
|
|
|
172
172
|
const columnsMarkup = STATUS_ORDER
|
|
@@ -178,6 +178,12 @@ function renderKanbanColumns(props) {
|
|
|
178
178
|
? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
|
|
179
179
|
: columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
|
|
180
180
|
|
|
181
|
+
// Re-apply drag-feedback class from store so a rerender during drag does
|
|
182
|
+
// not silently wipe the visual feedback that was set by handleDragover.
|
|
183
|
+
const feedbackClass = dragFeedback?.targetStatus === status
|
|
184
|
+
? (dragFeedback.kind === "valid" ? " board-drop-valid" : " board-drop-invalid")
|
|
185
|
+
: "";
|
|
186
|
+
|
|
181
187
|
return `
|
|
182
188
|
<section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
|
|
183
189
|
<header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
|
|
@@ -190,7 +196,7 @@ function renderKanbanColumns(props) {
|
|
|
190
196
|
</div>
|
|
191
197
|
${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
|
|
192
198
|
</header>
|
|
193
|
-
<div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
|
|
199
|
+
<div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain${feedbackClass}" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
|
|
194
200
|
</section>
|
|
195
201
|
`;
|
|
196
202
|
}).join("");
|
|
@@ -292,7 +298,7 @@ function render(props) {
|
|
|
292
298
|
});
|
|
293
299
|
|
|
294
300
|
const contentMarkup = store.view === "kanban"
|
|
295
|
-
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
|
|
301
|
+
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter, dragFeedback: store.dragFeedback ?? null })
|
|
296
302
|
: renderListView({ visibleTasks, selectedTaskId });
|
|
297
303
|
|
|
298
304
|
return `
|
|
@@ -255,6 +255,10 @@ export function shouldUseTaskModal(boardState, store) {
|
|
|
255
255
|
return Boolean(boardState?.taskModalOpen && boardState?.selectedTask);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
export function shouldUseSubtaskModal(boardState) {
|
|
259
|
+
return Boolean(boardState?.subtaskModalOpen && boardState?.selectedSubtask);
|
|
260
|
+
}
|
|
261
|
+
|
|
258
262
|
export function lookupNode(snapshot, id) {
|
|
259
263
|
return snapshot.tasks.find((task) => task.id === id)
|
|
260
264
|
?? snapshot.subtasks.find((subtask) => subtask.id === id)
|
|
@@ -310,6 +310,8 @@ export function createDelegation(rootElement, actions) {
|
|
|
310
310
|
for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
|
|
311
311
|
el.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
312
312
|
}
|
|
313
|
+
// Clear feedback in store so rerenders do not re-apply stale classes.
|
|
314
|
+
actions.setDragFeedback?.(null);
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
function handleDragstart(event) {
|
|
@@ -336,11 +338,15 @@ export function createDelegation(rootElement, actions) {
|
|
|
336
338
|
const targetStatus = column.dataset.dropStatus;
|
|
337
339
|
if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
|
|
338
340
|
event.preventDefault();
|
|
341
|
+
// Fast path: apply directly to DOM for immediate visual feedback.
|
|
339
342
|
column.classList.add("board-drop-valid");
|
|
340
343
|
column.classList.remove("board-drop-invalid");
|
|
344
|
+
// Also persist in store so a rerender during drag restores the class.
|
|
345
|
+
actions.setDragFeedback?.({ targetStatus, kind: "valid" });
|
|
341
346
|
} else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
|
|
342
347
|
column.classList.add("board-drop-invalid");
|
|
343
348
|
column.classList.remove("board-drop-valid");
|
|
349
|
+
actions.setDragFeedback?.({ targetStatus, kind: "invalid" });
|
|
344
350
|
}
|
|
345
351
|
}
|
|
346
352
|
|
|
@@ -348,6 +354,8 @@ export function createDelegation(rootElement, actions) {
|
|
|
348
354
|
const column = event.target.closest("[data-drop-status]");
|
|
349
355
|
if (column && !column.contains(event.relatedTarget)) {
|
|
350
356
|
column.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
357
|
+
// Clear store feedback when leaving the column.
|
|
358
|
+
actions.setDragFeedback?.(null);
|
|
351
359
|
}
|
|
352
360
|
}
|
|
353
361
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy overlay focus-trap controller.
|
|
3
|
+
*
|
|
4
|
+
* Attaches the document-level keydown/focusin listeners only while an overlay
|
|
5
|
+
* is actually open, and detaches them on close. Without this, plain Tab outside
|
|
6
|
+
* any overlay can be intercepted by a stale handler in the microtask window
|
|
7
|
+
* between overlay close and rerender.
|
|
8
|
+
*
|
|
9
|
+
* @param {{
|
|
10
|
+
* doc?: Document,
|
|
11
|
+
* onTabKey: (event: KeyboardEvent) => void,
|
|
12
|
+
* onFocusIn: (event: FocusEvent) => void,
|
|
13
|
+
* }} options
|
|
14
|
+
*/
|
|
15
|
+
export function createOverlayFocusTrap(options) {
|
|
16
|
+
const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
|
|
17
|
+
if (!doc) {
|
|
18
|
+
return {
|
|
19
|
+
attach() {},
|
|
20
|
+
detach() {},
|
|
21
|
+
isAttached() { return false; },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const onKeyDown = options.onTabKey;
|
|
26
|
+
const onFocusIn = options.onFocusIn;
|
|
27
|
+
let attached = false;
|
|
28
|
+
|
|
29
|
+
function attach() {
|
|
30
|
+
if (attached) return;
|
|
31
|
+
doc.addEventListener("keydown", onKeyDown, true);
|
|
32
|
+
doc.addEventListener("focusin", onFocusIn, true);
|
|
33
|
+
attached = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function detach() {
|
|
37
|
+
if (!attached) return;
|
|
38
|
+
doc.removeEventListener("keydown", onKeyDown, true);
|
|
39
|
+
doc.removeEventListener("focusin", onFocusIn, true);
|
|
40
|
+
attached = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
attach,
|
|
45
|
+
detach,
|
|
46
|
+
isAttached() { return attached; },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -142,6 +142,13 @@ export function createBoardActions(options) {
|
|
|
142
142
|
searchFocusKeys,
|
|
143
143
|
} = options;
|
|
144
144
|
const { store, persist, getBoardState, getTaskById, syncState } = model;
|
|
145
|
+
// Direct mutations of `store.*` (filter toggles, notice changes, drag
|
|
146
|
+
// feedback) bypass setState/syncState, so `getBoardState()`'s memo would
|
|
147
|
+
// otherwise return a stale reference until the next syncState/notify.
|
|
148
|
+
// Route these through invalidateBoardStateMemo() before rerender.
|
|
149
|
+
const invalidateMemo = typeof model.invalidateBoardStateMemo === "function"
|
|
150
|
+
? () => model.invalidateBoardStateMemo()
|
|
151
|
+
: () => {};
|
|
145
152
|
|
|
146
153
|
const transition = (patch = {}, options = {}) => {
|
|
147
154
|
const { persistState = true, rerenderBoard = true } = options;
|
|
@@ -348,13 +355,21 @@ export function createBoardActions(options) {
|
|
|
348
355
|
});
|
|
349
356
|
},
|
|
350
357
|
closeTask() {
|
|
351
|
-
transition({
|
|
358
|
+
transition({
|
|
359
|
+
selectedTaskId: null,
|
|
360
|
+
selectedSubtaskId: null,
|
|
361
|
+
taskModalOpen: false,
|
|
362
|
+
subtaskModalOpen: false,
|
|
363
|
+
});
|
|
352
364
|
},
|
|
353
365
|
openSubtask(subtaskId) {
|
|
354
|
-
transition(
|
|
366
|
+
transition(
|
|
367
|
+
{ selectedSubtaskId: subtaskId || null, subtaskModalOpen: Boolean(subtaskId) },
|
|
368
|
+
{ persistState: false },
|
|
369
|
+
);
|
|
355
370
|
},
|
|
356
371
|
closeSubtask() {
|
|
357
|
-
transition({ selectedSubtaskId: null }, { persistState: false });
|
|
372
|
+
transition({ selectedSubtaskId: null, subtaskModalOpen: false }, { persistState: false });
|
|
358
373
|
},
|
|
359
374
|
submitTaskForm(taskId, formData) {
|
|
360
375
|
const updates = {
|
|
@@ -413,12 +428,34 @@ export function createBoardActions(options) {
|
|
|
413
428
|
const task = getTaskById(taskId);
|
|
414
429
|
return task?.status ?? null;
|
|
415
430
|
},
|
|
431
|
+
/**
|
|
432
|
+
* Record the current drag-over feedback in store state so it survives a
|
|
433
|
+
* rerender triggered by an unrelated event (e.g. a server SSE push).
|
|
434
|
+
* Pass null to clear feedback (dragend / drop / dragleave).
|
|
435
|
+
*
|
|
436
|
+
* @param {{ targetStatus: string, kind: 'valid'|'invalid' }|null} feedback
|
|
437
|
+
*/
|
|
438
|
+
setDragFeedback(feedback) {
|
|
439
|
+
// Reference equality on freshly-allocated `{ targetStatus, kind }` always
|
|
440
|
+
// fails, so dragover would rerender on every move. Compare by primitive
|
|
441
|
+
// key so repeats over the same column become a no-op.
|
|
442
|
+
const prev = store.dragFeedback;
|
|
443
|
+
const prevKey = prev ? `${prev.targetStatus}|${prev.kind}` : null;
|
|
444
|
+
const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
|
|
445
|
+
if (prevKey === nextKey) return;
|
|
446
|
+
store.dragFeedback = feedback;
|
|
447
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
448
|
+
model.invalidateBoardStateMemo();
|
|
449
|
+
}
|
|
450
|
+
rerender();
|
|
451
|
+
},
|
|
416
452
|
dropTaskStatus(taskId, nextStatus) {
|
|
417
453
|
const task = getTaskById(taskId);
|
|
418
454
|
if (!task || !nextStatus || task.status === nextStatus) {
|
|
419
455
|
return;
|
|
420
456
|
}
|
|
421
|
-
|
|
457
|
+
// Drag/drop is a status change only; do not mutate selection or modal state,
|
|
458
|
+
// otherwise dropping a card while another task modal is open hijacks it.
|
|
422
459
|
api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
|
|
423
460
|
},
|
|
424
461
|
changeEpicStatus(epicId, newStatus) {
|
|
@@ -438,22 +475,26 @@ export function createBoardActions(options) {
|
|
|
438
475
|
toggleEpicStatusFilter(status) {
|
|
439
476
|
const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
440
477
|
store.epicStatusFilter = { ...current, [status]: !current[status] };
|
|
478
|
+
invalidateMemo();
|
|
441
479
|
persist();
|
|
442
480
|
rerender();
|
|
443
481
|
},
|
|
444
482
|
toggleTaskStatusFilter(status) {
|
|
445
483
|
const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
446
484
|
store.taskStatusFilter = { ...current, [status]: !current[status] };
|
|
485
|
+
invalidateMemo();
|
|
447
486
|
persist();
|
|
448
487
|
rerender();
|
|
449
488
|
},
|
|
450
489
|
resetEpicFilter() {
|
|
451
490
|
store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
491
|
+
invalidateMemo();
|
|
452
492
|
persist();
|
|
453
493
|
rerender();
|
|
454
494
|
},
|
|
455
495
|
resetTaskFilter() {
|
|
456
496
|
store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
497
|
+
invalidateMemo();
|
|
457
498
|
persist();
|
|
458
499
|
rerender();
|
|
459
500
|
},
|