local-kanban 1.6.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,919 @@
1
+
2
+ import { WORKING, buildSelect, canMoveTo, clearSelection, labelChip, moveBlocked, scheduleDrawLinks, selVal, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
3
+ import { $, ALL, ALL_STATUSES, DASH, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, SETTINGS, TITLE_LABEL_WORDS, api, apiBlob, esc, ghSyncOn, ic, noteUnsaved, seg, state, tr } from './core.js';
4
+ import { getSetting } from './settings.js';
5
+ import { currentProject, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm } from './sidebar.js';
6
+ import { autoGrow, copyText, refresh, relTime, renderTimeline } from './sse.js';
7
+
8
+ /* ── Drawer ── */
9
+ let drawerLabels = [];
10
+ let drawerLocked = false;
11
+ let drawerStatus = null;
12
+ let drawerPinned = 0;
13
+ let createInFlight = false;
14
+ let drawerSnapshot = null;
15
+ const sameLabels = (a, b) => a.length === b.length && new Set([...a, ...b]).size === new Set(a).size;
16
+ const fieldsSnapshot = (t) => ({
17
+ title: t.title || '', description: t.description || '', priority: t.priority, labels: [...(t.labels || [])], status: t.status,
18
+ });
19
+ function changedFields(next, snap) {
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(next)) {
22
+ const same = k === 'labels' ? sameLabels(v, snap[k] || []) : v === snap[k];
23
+ if (!same) out[k] = v;
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function applyTitleLabelShortcut() {
29
+ const el = $('d-title');
30
+ const m = el.value.match(/^(\S+)[ \t]/);
31
+ if (!m) return;
32
+ const label = TITLE_LABEL_WORDS[m[1].toLowerCase()];
33
+ if (!label) return;
34
+ el.value = el.value.slice(m[0].length);
35
+ autoGrow(el);
36
+ if (!drawerLabels.includes(label)) {
37
+ drawerLabels = [...drawerLabels, label];
38
+ renderDrawerLabels();
39
+ if (label === 'noclaude' && state.drawerKey) {
40
+ buildStatusSelect(drawerStatus, false, true);
41
+ setDrawerLock(false);
42
+ }
43
+ }
44
+ }
45
+ function renderDrawerLabels() {
46
+ const box = $('d-labels');
47
+ const askOn = drawerLabels.includes('ask');
48
+ box.innerHTML = drawerLabels.filter((l) => l !== 'ask').map(labelChip).join('')
49
+ + (drawerLocked
50
+ ? (askOn ? labelChip('ask') : '')
51
+ : `<span class="link-chip link-add" id="lbl-add">${tr('+ label')}</span>`
52
+ + `<span class="link-chip link-add lbl-ask${askOn ? ' on' : ''}" id="lbl-ask" title="${esc(tr('Claude will ask clarifying questions first and wait for your answer'))}">ask?</span>`);
53
+ if (drawerLocked) return;
54
+ $('lbl-add').onclick = openLabelPicker;
55
+ $('lbl-ask').onclick = () => {
56
+ drawerLabels = askOn ? drawerLabels.filter((l) => l !== 'ask') : [...drawerLabels, 'ask'];
57
+ renderDrawerLabels();
58
+ doAutosave();
59
+ };
60
+ }
61
+
62
+ const PRI_OPTIONS = [...PRI_LEVELS].reverse().map(([v, label]) => ({ value: String(v), label }));
63
+ export function statusOptionList(current, isNew, allowDirect) {
64
+ return ALL_STATUSES.map(([v, label]) => ({
65
+ value: v, label, dot: v,
66
+ disabled: Boolean(moveBlocked(current, v, { manual: allowDirect, isNew })),
67
+ }));
68
+ }
69
+ function onDrawerStatusChange(v) {
70
+ $('d-return').classList.toggle('hidden', v !== 'review');
71
+ $('d-return-icon').classList.toggle('hidden', !['review', 'done'].includes(v));
72
+ $('d-accept-btn').classList.toggle('hidden', v !== 'review');
73
+ drawerStatus = v;
74
+ doAutosave();
75
+ }
76
+ function buildStatusSelect(current, isNew, allowDirect) {
77
+ buildSelect($('d-status'), { value: current, options: statusOptionList(current, isNew, allowDirect), onChange: onDrawerStatusChange });
78
+ }
79
+ function buildPrioritySelect(current) {
80
+ buildSelect($('d-priority'), { value: String(current), options: PRI_OPTIONS, onChange: doAutosave });
81
+ }
82
+
83
+ const LOCKABLE = ['d-title', 'd-desc'];
84
+ function setDrawerLock(locked) {
85
+ drawerLocked = locked;
86
+ for (const id of LOCKABLE) $(id).disabled = locked;
87
+ ['d-status', 'd-priority', 'd-project'].forEach((id) => $(id).classList.toggle('kbsel-locked', locked));
88
+ $('d-lock-note').classList.toggle('hidden', !locked);
89
+ $('drawer-menu').classList.toggle('hidden', locked || !state.drawerKey);
90
+ }
91
+
92
+ function openLabelPicker(e) {
93
+ closeLabelPicker();
94
+ pushLayer('lbl-picker', closeLabelPicker);
95
+ const picker = document.createElement('div');
96
+ picker.className = 'lbl-picker';
97
+ picker.id = 'lbl-picker';
98
+ for (const name of LABEL_SELECTABLE) {
99
+ const opt = document.createElement('div');
100
+ opt.className = 'lbl-opt';
101
+ opt.innerHTML = `<input type="checkbox" ${drawerLabels.includes(name) ? 'checked' : ''}>
102
+ <span class="lbl-dot" style="width:9px;height:9px;border-radius:50%;background:${LABEL_COLORS[name]}"></span> ${esc(name)}`;
103
+ opt.onclick = () => {
104
+ drawerLabels = drawerLabels.includes(name) ? drawerLabels.filter((l) => l !== name) : [...drawerLabels, name];
105
+ renderDrawerLabels();
106
+ if (name === 'noclaude' && state.drawerKey) {
107
+ buildStatusSelect(drawerStatus, false, drawerLabels.includes('noclaude'));
108
+ setDrawerLock(WORKING.has(drawerStatus) && !drawerLabels.includes('noclaude'));
109
+ }
110
+ opt.querySelector('input').checked = drawerLabels.includes(name);
111
+ doAutosave();
112
+ };
113
+ picker.appendChild(opt);
114
+ }
115
+ const r = e?.target?.getBoundingClientRect?.() || $('d-labels').getBoundingClientRect();
116
+ picker.style.left = Math.min(r.left, window.innerWidth - 240) + 'px';
117
+ picker.style.top = r.bottom + 6 + 'px';
118
+ document.body.appendChild(picker);
119
+ setTimeout(() => document.addEventListener('click', pickerOutside), 0);
120
+ }
121
+ function pickerOutside(e) {
122
+ const p = $('lbl-picker');
123
+ if (p && !p.contains(e.target) && e.target.id !== 'lbl-add') closeLabelPicker();
124
+ }
125
+ function closeLabelPicker() {
126
+ popLayer('lbl-picker');
127
+ $('lbl-picker')?.remove();
128
+ document.removeEventListener('click', pickerOutside);
129
+ }
130
+
131
+ const REL_MARK = { child: '↳ ', parent: '↑ ' };
132
+ const REL_TITLE = { child: tr('subtask'), parent: tr('parent task'), related: tr('related') };
133
+ function renderDrawerLinks(links) {
134
+ const box = $('d-links');
135
+ box.innerHTML = (links || []).map((l) =>
136
+ `<span class="link-chip rel-${l.rel || 'related'}" title="${REL_TITLE[l.rel] || tr('related')}: ${esc(l.title)}"><span class="dot s-${l.status}"></span>${REL_MARK[l.rel] || ''}${esc(l.key)}`
137
+ + (drawerLocked ? '' : `<span class="link-x" data-other="${l.id}" title="${esc(tr('remove link'))}">×</span>`) + '</span>',
138
+ ).join('') + (drawerLocked ? '' : `<span class="link-chip link-add" id="link-add">${tr('+ link')}</span>`);
139
+ box.classList.remove('hidden');
140
+ if (drawerLocked) return;
141
+ $('link-add').onclick = () => openLinkPicker('related', state.drawerKey);
142
+ box.querySelectorAll('.link-x').forEach((x) => { x.onclick = (e) => { e.stopPropagation(); deleteLink(x.dataset.other); }; });
143
+ }
144
+
145
+ export function renderIssueRow(t) {
146
+ const row = $('d-issue-row');
147
+ if (!t.gh_issue_number) { row.innerHTML = ghSyncOn ? `<span class="muted">${tr('creating issue…')}</span>` : ''; return; }
148
+ if (!t.gh_issue_url) { row.innerHTML = `<span class="muted">issue #${t.gh_issue_number}</span>`; return; }
149
+ let html = `<a class="issue-link" href="${esc(t.gh_issue_url)}" target="_blank" rel="noopener noreferrer">issue #${esc(t.gh_issue_number)} ↗</a>`;
150
+ if (t.commit_url) html += `<a class="issue-link commit-link" href="${esc(t.commit_url)}" target="_blank" rel="noopener noreferrer" title="${tr('the fix commit')}">commit ↗</a>`;
151
+ row.innerHTML = html;
152
+ }
153
+
154
+ async function deleteLink(otherId) {
155
+ if (!state.drawerKey) return;
156
+ const links = await api('DELETE', `/api/tasks/${seg(state.drawerKey)}/links/${seg(otherId)}`);
157
+ renderDrawerLinks(links);
158
+ await refresh();
159
+ }
160
+
161
+ const COMMENT_AUTHORS = { claude: () => `${ic('bot', 12)} claude`, git: () => `${ic('git', 12)} git`, me: () => `${ic('person', 12)} ${tr('me')}` };
162
+
163
+ export async function renderComments(key) {
164
+ const cs = await api('GET', `/api/tasks/${seg(key)}/comments`);
165
+ $('comments').innerHTML = cs.map((c) => `
166
+ <div class="comment" data-id="${c.id}">
167
+ <div class="comment-meta"><span>${(COMMENT_AUTHORS[c.author] || COMMENT_AUTHORS.me)()} · ${relTime(c.created_at)}</span>
168
+ <span class="comment-del" data-del="${c.id}" title="${tr('Delete the comment')}">×</span></div>
169
+ ${c.body ? `<div class="comment-body">${esc(c.body)}</div>` : ''}
170
+ ${c.image_url ? `<img class="comment-img" src="${c.image_url}" data-url="${c.image_url}" alt="${tr('screenshot')}" loading="lazy">` : ''}
171
+ </div>`).join('');
172
+ $('comments').querySelectorAll('.comment-img').forEach((img) => { img.onclick = () => openLightbox(img.dataset.url); });
173
+ $('comments').querySelectorAll('.comment-del').forEach((x) => { x.onclick = () => deleteComment(x.dataset.del); });
174
+ }
175
+
176
+ async function deleteComment(id) {
177
+ if (!state.drawerKey) return;
178
+ if (!await styledConfirm(tr('Delete the comment?'), { okLabel: tr('Delete'), danger: true })) return;
179
+ await api('DELETE', `/api/tasks/${seg(state.drawerKey)}/comments/${seg(id)}`);
180
+ renderComments(state.drawerKey);
181
+ await refresh();
182
+ }
183
+
184
+ function renderAttachments(list) {
185
+ const box = $('d-attachments');
186
+ if (!list || !list.length) { box.innerHTML = ''; box.classList.add('hidden'); return; }
187
+ box.innerHTML = list.map((a) =>
188
+ `<div class="attach"><img src="${a.url}" data-url="${a.url}" alt="${tr('attachment')}" loading="lazy">`
189
+ + `<span class="attach-x" data-id="${a.id}" title="${tr('delete')}">×</span></div>`).join('');
190
+ box.classList.remove('hidden');
191
+ box.querySelectorAll('.attach img').forEach((img) => { img.onclick = () => openLightbox(img.dataset.url); });
192
+ box.querySelectorAll('.attach-x').forEach((x) => { x.onclick = () => deleteAttachment(x.dataset.id); });
193
+ }
194
+
195
+ let drawerChecklist = [];
196
+ const clSaveTimers = {};
197
+ function renderChecklist(list) {
198
+ drawerChecklist = list || [];
199
+ const box = $('d-checklist');
200
+ if (!drawerChecklist.length) { box.innerHTML = ''; box.classList.add('hidden'); }
201
+ else {
202
+ box.classList.remove('hidden');
203
+ const trashSvg = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M2.5 4h11M6.5 4V2.8a.8.8 0 0 1 .8-.8h1.4a.8.8 0 0 1 .8.8V4M5 4l.5 9a1 1 0 0 0 1 .95h3a1 1 0 0 0 1-.95L12 4M6.7 6.5v5M9.3 6.5v5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>';
204
+ box.innerHTML = drawerChecklist.map((c) =>
205
+ `<div class="cl-item${c.done ? ' done' : ''}" data-id="${c.id}">`
206
+ + `<button class="cl-check" title="${esc(c.done ? tr('clear the mark') : tr('mark as done'))}">${c.done ? '✓' : ''}</button>`
207
+ + `<textarea class="cl-text" rows="1" placeholder="${esc(tr('item'))}"${(drawerLocked || c.done) ? ' readonly' : ''}>${esc(c.text)}</textarea>`
208
+ + (drawerLocked ? '' : `<button class="cl-del" title="${esc(tr('delete item'))}">${trashSvg}</button>`)
209
+ + '</div>').join('');
210
+ box.querySelectorAll('.cl-item').forEach((el) => {
211
+ const id = el.dataset.id;
212
+ const done = el.classList.contains('done');
213
+ const ta = el.querySelector('.cl-text');
214
+ autoGrow(ta);
215
+ if (!drawerLocked) el.querySelector('.cl-check').onclick = () => toggleChecklist(id);
216
+ if (!drawerLocked) {
217
+ if (!done) {
218
+ ta.addEventListener('input', () => { autoGrow(ta); scheduleChecklistSave(id, ta.value); });
219
+ ta.addEventListener('keydown', (e) => {
220
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addChecklistItem(); }
221
+ else if (e.key === 'Backspace' && ta.value === '') { e.preventDefault(); deleteChecklistItem(id); }
222
+ });
223
+ }
224
+ el.querySelector('.cl-del').onclick = () => deleteChecklistItem(id);
225
+ }
226
+ });
227
+ }
228
+ $('d-checklist-add').classList.toggle('hidden', !state.drawerKey || drawerLocked);
229
+ }
230
+ async function toggleChecklist(id) {
231
+ if (!state.drawerKey || drawerLocked) return;
232
+ const item = drawerChecklist.find((c) => String(c.id) === String(id));
233
+ if (!item) return;
234
+ item.done = !item.done;
235
+ renderChecklist(drawerChecklist);
236
+ try { await api('PATCH', `/api/tasks/${seg(state.drawerKey)}/checklist/${seg(id)}`, { done: item.done }); } catch { }
237
+ }
238
+ function scheduleChecklistSave(id, text) {
239
+ const item = drawerChecklist.find((c) => String(c.id) === String(id));
240
+ if (item) item.text = text;
241
+ clearTimeout(clSaveTimers[id]);
242
+ clSaveTimers[id] = setTimeout(async () => {
243
+ if (!state.drawerKey) return;
244
+ try { await api('PATCH', `/api/tasks/${seg(state.drawerKey)}/checklist/${seg(id)}`, { text }); } catch { }
245
+ }, 450);
246
+ }
247
+ export async function addChecklistItem() {
248
+ if (!state.drawerKey || drawerLocked) return;
249
+ const created = await api('POST', `/api/tasks/${seg(state.drawerKey)}/checklist`, { text: '' });
250
+ drawerChecklist.push({ id: created.id, text: '', done: false, position: created.position });
251
+ renderChecklist(drawerChecklist);
252
+ $('d-checklist').querySelector(`.cl-item[data-id="${created.id}"] .cl-text`)?.focus();
253
+ }
254
+ async function deleteChecklistItem(id) {
255
+ if (!state.drawerKey) return;
256
+ clearTimeout(clSaveTimers[id]);
257
+ drawerChecklist = drawerChecklist.filter((c) => String(c.id) !== String(id));
258
+ renderChecklist(drawerChecklist);
259
+ try { await api('DELETE', `/api/tasks/${seg(state.drawerKey)}/checklist/${seg(id)}`); } catch { }
260
+ }
261
+
262
+ export function openLightbox(url) {
263
+ const overlay = document.createElement('div');
264
+ overlay.className = 'lightbox';
265
+ overlay.innerHTML = `<img src="${esc(url)}" alt="">`;
266
+ const close = overlayLayer(overlay, { backdrop: false });
267
+ overlay.onclick = close;
268
+ document.body.appendChild(overlay);
269
+ }
270
+
271
+ async function deleteAttachment(attId) {
272
+ if (!state.drawerKey) return;
273
+ await api('DELETE', `/api/tasks/${seg(state.drawerKey)}/attachments/${seg(attId)}`);
274
+ const t = await api('GET', `/api/tasks/${seg(state.drawerKey)}`);
275
+ renderAttachments(t.attachments);
276
+ }
277
+
278
+ export async function uploadAttachment(blob, key = state.drawerKey) {
279
+ if (!key) return;
280
+ try {
281
+ await apiBlob(`/api/tasks/${seg(key)}/attachments`, blob);
282
+ } catch (e) { styledAlert(e.message || tr('could not attach')); return; }
283
+ if (key !== state.drawerKey) return;
284
+ const t = await api('GET', `/api/tasks/${seg(state.drawerKey)}`);
285
+ renderAttachments(t.attachments);
286
+ }
287
+
288
+ export let pendingAttachments = [];
289
+ function clearPending() {
290
+ for (const p of pendingAttachments) URL.revokeObjectURL(p.url);
291
+ pendingAttachments = [];
292
+ }
293
+ function renderPendingAttachments() {
294
+ const box = $('d-attachments');
295
+ if (!pendingAttachments.length) { box.innerHTML = ''; box.classList.add('hidden'); return; }
296
+ box.innerHTML = pendingAttachments.map((p, i) =>
297
+ `<div class="attach"><img src="${p.url}" alt="${tr('attachment')}"><span class="attach-x" data-i="${i}" title="${tr('delete')}">×</span></div>`).join('');
298
+ box.classList.remove('hidden');
299
+ box.querySelectorAll('.attach img').forEach((img) => { img.onclick = () => openLightbox(img.getAttribute('src')); });
300
+ box.querySelectorAll('.attach-x').forEach((x) => { x.onclick = () => {
301
+ const i = Number(x.dataset.i);
302
+ URL.revokeObjectURL(pendingAttachments[i].url);
303
+ pendingAttachments.splice(i, 1);
304
+ renderPendingAttachments();
305
+ }; });
306
+ }
307
+
308
+ export function onDrawerPaste(e) {
309
+ const imgs = [...(e.clipboardData?.items || [])].filter((i) => i.type.startsWith('image/'));
310
+ if (!imgs.length) return;
311
+ e.preventDefault();
312
+ const intoComment = e.target.id === 'c-input' && state.drawerKey;
313
+ for (const it of imgs) {
314
+ const blob = it.getAsFile();
315
+ if (!blob) continue;
316
+ if (intoComment) uploadCommentImage(blob);
317
+ else if (state.drawerKey) uploadAttachment(blob);
318
+ else {
319
+ pendingAttachments.push({ blob, url: URL.createObjectURL(blob) });
320
+ renderPendingAttachments();
321
+ scheduleAutosave();
322
+ }
323
+ }
324
+ }
325
+
326
+ async function uploadCommentImage(blob) {
327
+ if (!state.drawerKey) return;
328
+ try {
329
+ await apiBlob(`/api/tasks/${seg(state.drawerKey)}/comments`, blob);
330
+ } catch (e) { styledAlert(e.message || tr('could not attach the screenshot')); return; }
331
+ renderComments(state.drawerKey);
332
+ }
333
+
334
+ function markSelectedCard() {
335
+ document.querySelectorAll('.card.selected').forEach((c) => c.classList.remove('selected'));
336
+ if (!state.drawerKey) return;
337
+ const t = state.tasks.find((x) => x.key === state.drawerKey);
338
+ if (t) $('board').querySelector(`.card[data-id="${t.id}"]`)?.classList.add('selected');
339
+ }
340
+
341
+ function kbColumns() {
342
+ return [...document.querySelectorAll('#board .col')]
343
+ .map((col) => ({ status: col.querySelector('.col-body')?.dataset.status, cards: [...col.querySelectorAll('.col-body .card')] }))
344
+ .filter((c) => c.status);
345
+ }
346
+ function kbFindCursor(cols) {
347
+ if (state.kbCursor == null) return null;
348
+ for (let ci = 0; ci < cols.length; ci++) {
349
+ const ri = cols[ci].cards.findIndex((c) => Number(c.dataset.id) === state.kbCursor);
350
+ if (ri >= 0) return { ci, ri };
351
+ }
352
+ return null;
353
+ }
354
+ export function markKbCursor() {
355
+ document.querySelectorAll('.card.kb-cursor').forEach((c) => c.classList.remove('kb-cursor'));
356
+ if (state.kbCursor == null) return;
357
+ $('board').querySelector(`.card[data-id="${state.kbCursor}"]`)?.classList.add('kb-cursor');
358
+ }
359
+ export function setKbCursor(id, scroll = true) {
360
+ state.kbCursor = id;
361
+ markKbCursor();
362
+ if (id != null && scroll) $('board').querySelector(`.card[data-id="${id}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
363
+ }
364
+ export function kbMoveCursor(dr, dc) {
365
+ const cols = kbColumns();
366
+ if (!cols.length) return;
367
+ const pos = kbFindCursor(cols);
368
+ if (!pos) {
369
+ const ci = cols.findIndex((c) => c.cards.length);
370
+ if (ci >= 0) setKbCursor(Number(cols[ci].cards[0].dataset.id));
371
+ return;
372
+ }
373
+ let { ci, ri } = pos;
374
+ if (dr) {
375
+ ri = Math.max(0, Math.min(cols[ci].cards.length - 1, ri + dr));
376
+ } else if (dc) {
377
+ let nci = ci + dc;
378
+ while (nci >= 0 && nci < cols.length && !cols[nci].cards.length) nci += dc;
379
+ if (nci < 0 || nci >= cols.length || !cols[nci].cards.length) return;
380
+ ci = nci;
381
+ ri = Math.min(ri, cols[ci].cards.length - 1);
382
+ }
383
+ setKbCursor(Number(cols[ci].cards[ri].dataset.id));
384
+ syncDrawerToCursor();
385
+ }
386
+ let cursorDrawerTimer = null;
387
+ function syncDrawerToCursor() {
388
+ if (!state.drawerKey || state.kbCursor == null) return;
389
+ clearTimeout(cursorDrawerTimer);
390
+ cursorDrawerTimer = setTimeout(() => {
391
+ if (!state.drawerKey || state.kbCursor == null) return;
392
+ const t = state.tasks.find((x) => x.id === state.kbCursor);
393
+ if (t && t.key !== state.drawerKey) openDrawer(t.key);
394
+ }, 150);
395
+ }
396
+ const kbBoardSort = (a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id;
397
+ export async function kbMoveCard(dr, dc) {
398
+ const t = state.tasks.find((x) => x.id === state.kbCursor);
399
+ if (!t) return;
400
+ if (dr) {
401
+ const sorted = state.tasks.filter((x) => x.status === t.status).sort(kbBoardSort);
402
+ const ri = sorted.findIndex((x) => x.id === t.id);
403
+ const ti = Math.max(0, Math.min(sorted.length - 1, ri + dr));
404
+ if (ti === ri) return;
405
+ const others = sorted.filter((x) => x.id !== t.id);
406
+ const prev = others[ti - 1]?.position ?? 0;
407
+ const next = others[ti]?.position ?? prev + 2;
408
+ await api('PATCH', `/api/tasks/${seg(t.id)}`, { position: (prev + next) / 2 });
409
+ } else if (dc) {
410
+ const cols = kbColumns();
411
+ const ci = cols.findIndex((c) => c.status === t.status);
412
+ if (ci < 0) return;
413
+ let nci = ci + dc;
414
+ while (nci >= 0 && nci < cols.length && WORKING.has(cols[nci].status)) nci += dc;
415
+ if (nci < 0 || nci >= cols.length) return;
416
+ const target = cols[nci].status;
417
+ if (!canMoveTo(t.status, target, t)) return;
418
+ await api('PATCH', `/api/tasks/${seg(t.id)}`, { status: target });
419
+ } else return;
420
+ await refresh();
421
+ setKbCursor(t.id);
422
+ }
423
+
424
+ export const commentDrafts = new Map();
425
+ export function applyCommentDraft(key) {
426
+ const box = $('c-input');
427
+ box.value = (key && commentDrafts.get(key)) || '';
428
+ autoGrow(box);
429
+ }
430
+
431
+ function setDrawerId(key, slug) {
432
+ const el = $('drawer-id');
433
+ const proj = state.projects.find((x) => x.slug === slug);
434
+ el.innerHTML = `${esc(key)} · <span class="drawer-proj" title="${esc(tr('Go to project'))}">${esc(proj?.name || slug)}</span>`;
435
+ el.querySelector('.drawer-proj').onclick = () => selectProject(slug);
436
+ }
437
+
438
+ export function openDrawer(key) {
439
+ if (selectedIds.size) clearSelection();
440
+ if (autosaveTimer && state.drawerKey && state.drawerKey !== key) { clearTimeout(autosaveTimer); autosaveTimer = null; doAutosave(); }
441
+ api('GET', `/api/tasks/${seg(key)}`, undefined, { quiet: true }).then((t) => {
442
+ state.drawerKey = t.key;
443
+ $('drawer').classList.remove('is-empty');
444
+ $('d-project').classList.add('hidden');
445
+ setDrawerLock(WORKING.has(t.status) && !taskIsNoclaude(t));
446
+ setDrawerId(t.key, t.project);
447
+ $('d-title').value = t.title;
448
+ $('d-desc').value = t.description || '';
449
+ drawerStatus = t.status;
450
+ buildStatusSelect(t.status, false, taskIsNoclaude(t));
451
+ buildPrioritySelect(t.priority);
452
+ drawerLabels = t.labels;
453
+ drawerSnapshot = fieldsSnapshot(t);
454
+ drawerPinned = t.pinned || 0;
455
+ renderDrawerLabels();
456
+ renderDrawerLinks(t.links);
457
+ renderIssueRow(t);
458
+ $('d-return').classList.toggle('hidden', t.status !== 'review');
459
+ $('d-accept-btn').classList.toggle('hidden', t.status !== 'review');
460
+ $('d-return-icon').classList.toggle('hidden', !['review', 'done'].includes(t.status));
461
+ closeReturnPop();
462
+ $('comments-block').classList.remove('hidden');
463
+ renderComments(t.key);
464
+ renderAttachments(t.attachments);
465
+ renderChecklist(t.checklist);
466
+ $('drawer').classList.remove('hidden');
467
+ applyCommentDraft(t.key);
468
+ renderTimeline(t);
469
+ autoGrow($('d-title'));
470
+ markSelectedCard();
471
+ scheduleDrawLinks();
472
+ const card = $('board').querySelector(`.card[data-id="${t.id}"]`);
473
+ if (card) requestAnimationFrame(() => card.scrollIntoView({ inline: 'nearest', block: 'nearest' }));
474
+ }, (e) => {
475
+ if (e?.status !== 404) { styledAlert(tr('The board is not responding — try again in a moment.')); return; }
476
+ styledAlert(tr('This task no longer exists — it was deleted.'));
477
+ if (state.drawerKey === key) closeDrawer();
478
+ refresh();
479
+ });
480
+ }
481
+
482
+ export function openDrawerNew(status = 'backlog') {
483
+ if (selectedIds.size) clearSelection();
484
+ state.drawerKey = null;
485
+ markSelectedCard();
486
+ state.drawerProject = state.slug === ALL ? null : state.slug;
487
+ $('drawer').classList.remove('is-empty');
488
+ setDrawerLock(false);
489
+ if (state.slug === ALL) {
490
+ buildSelect($('d-project'), { value: state.projects[0]?.slug, options: state.projects.map((p) => ({ value: p.slug, label: p.slug })) });
491
+ $('d-project').classList.remove('hidden');
492
+ } else {
493
+ $('d-project').classList.add('hidden');
494
+ }
495
+ $('drawer-id').textContent = `${tr('new')} · ${state.slug === ALL ? tr('pick a project') : (currentProject()?.name || state.slug)}`;
496
+ $('d-title').value = '';
497
+ $('d-desc').value = '';
498
+ drawerStatus = status;
499
+ drawerSnapshot = null;
500
+ buildStatusSelect(status, true);
501
+ buildPrioritySelect(0);
502
+ drawerLabels = [];
503
+ renderDrawerLabels();
504
+ $('d-links').classList.add('hidden');
505
+ $('d-issue-row').innerHTML = '';
506
+ $('d-return').classList.add('hidden');
507
+ $('d-accept-btn').classList.add('hidden');
508
+ $('d-return-icon').classList.add('hidden');
509
+ $('drawer-menu').classList.add('hidden');
510
+ $('d-timeline').classList.add('hidden');
511
+ clearPending();
512
+ renderPendingAttachments();
513
+ renderChecklist([]);
514
+ $('comments').innerHTML = '';
515
+ $('comments-block').classList.add('hidden');
516
+ $('drawer').classList.remove('hidden');
517
+ applyCommentDraft(null);
518
+ scheduleDrawLinks();
519
+ autoGrow($('d-title'));
520
+ $('d-title').focus();
521
+ }
522
+
523
+ export function closeDrawer() {
524
+ if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; if (state.drawerKey) doAutosave(); }
525
+ closeDrawerMenu();
526
+ closeReturnPop();
527
+ state.drawerKey = null;
528
+ drawerStatus = null;
529
+ drawerSnapshot = null;
530
+ closeLabelPicker();
531
+ clearPending();
532
+ markSelectedCard();
533
+ if (getSetting('keepDrawer')) { $('drawer').classList.remove('hidden'); $('drawer').classList.add('is-empty'); $('drawer-id').textContent = ''; }
534
+ else $('drawer').classList.add('hidden');
535
+ scheduleDrawLinks();
536
+ }
537
+
538
+ export function applyKeepDrawer() {
539
+ if (state.drawerKey) return;
540
+ if (getSetting('keepDrawer')) { $('drawer').classList.remove('hidden'); $('drawer').classList.add('is-empty'); $('drawer-id').textContent = ''; }
541
+ else { $('drawer').classList.add('hidden'); $('drawer').classList.remove('is-empty'); }
542
+ scheduleDrawLinks();
543
+ }
544
+
545
+ function closeDrawerMenu() {
546
+ popLayer('drawer-menu-pop');
547
+ $('drawer-menu-pop')?.remove();
548
+ document.removeEventListener('click', drawerMenuOutside);
549
+ }
550
+ function drawerMenuOutside(e) {
551
+ if (!e.target.closest('#drawer-menu-pop') && e.target.id !== 'drawer-menu') closeDrawerMenu();
552
+ }
553
+ export function openDrawerMenu() {
554
+ if ($('drawer-menu-pop')) { closeDrawerMenu(); return; }
555
+ if (!state.drawerKey) return;
556
+ const pinned = state.tasks.find((x) => x.key === state.drawerKey)?.pinned ?? drawerPinned;
557
+ pushLayer('drawer-menu-pop', closeDrawerMenu);
558
+ const menu = document.createElement('div');
559
+ menu.className = 'drawer-menu-pop';
560
+ menu.id = 'drawer-menu-pop';
561
+ menu.innerHTML = `<div class="dm-item" data-act="subtask">${tr('Add subtask')}</div>`
562
+ + `<div class="dm-item" data-act="parent">${tr('Link parent task')}</div>`
563
+ + `<div class="dm-item" data-act="pin">${pinned ? tr('Unpin') : tr('Pin')}</div>`
564
+ + '<div class="dm-sep"></div>'
565
+ + `<div class="dm-item" data-act="cancel">${tr('Cancel task')}</div>`
566
+ + `<div class="dm-item dm-danger" data-act="delete">${tr('Delete')}</div>`;
567
+ document.body.appendChild(menu);
568
+ const r = $('drawer-menu').getBoundingClientRect();
569
+ menu.style.top = `${r.bottom + 6}px`;
570
+ menu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
571
+ requestAnimationFrame(() => menu.classList.add('open'));
572
+ menu.querySelector('[data-act="subtask"]').onclick = () => { closeDrawerMenu(); openLinkPicker('child', state.drawerKey); };
573
+ menu.querySelector('[data-act="parent"]').onclick = () => { closeDrawerMenu(); openLinkPicker('parent', state.drawerKey); };
574
+ menu.querySelector('[data-act="pin"]').onclick = () => togglePinTask(state.drawerKey);
575
+ menu.querySelector('[data-act="cancel"]').onclick = () => cancelTask(state.drawerKey);
576
+ menu.querySelector('[data-act="delete"]').onclick = () => deleteTask(state.drawerKey);
577
+ setTimeout(() => document.addEventListener('click', drawerMenuOutside), 0);
578
+ }
579
+
580
+ async function togglePinTask(key) {
581
+ closeDrawerMenu(); closeCardMenu();
582
+ if (!key) return;
583
+ const cur = (key === state.drawerKey) ? { pinned: drawerPinned } : state.tasks.find((x) => x.key === key);
584
+ const next = cur?.pinned ? 0 : 1;
585
+ await api('PATCH', `/api/tasks/${seg(key)}`, { pinned: next });
586
+ if (key === state.drawerKey) drawerPinned = next;
587
+ await refresh();
588
+ }
589
+
590
+ function openLinkPicker(rel, key) {
591
+ if (!key) return;
592
+ const cur = state.tasks.find((x) => x.key === key);
593
+ const project = cur?.project || (![ALL, DASH, SETTINGS].includes(state.slug) ? state.slug : null);
594
+ const overlay = document.createElement('div');
595
+ overlay.className = 'modal-overlay';
596
+ overlay.innerHTML = `<div class="modal lp-modal">
597
+ <div class="modal-title">${rel === 'parent' ? tr('Link a parent task') : rel === 'related' ? tr('Link a task') : tr('Add a subtask')}</div>
598
+ <input class="modal-input" id="lp-input" placeholder="${tr('Search, or the title of a new task…')}" autocomplete="off">
599
+ <div class="lp-results" id="lp-results"></div>
600
+ <div class="modal-err hidden" id="lp-err"></div>
601
+ </div>`;
602
+ document.body.appendChild(overlay);
603
+ const input = overlay.querySelector('#lp-input');
604
+ const results = overlay.querySelector('#lp-results');
605
+ const errEl = overlay.querySelector('#lp-err');
606
+ const close = overlayLayer(overlay);
607
+ const fail = (m) => { errEl.textContent = m; errEl.classList.remove('hidden'); };
608
+ async function link(otherKey) {
609
+ try { const links = await api('POST', `/api/tasks/${seg(key)}/links`, { key: otherKey, rel }); if (key === state.drawerKey) renderDrawerLinks(links); }
610
+ catch (e) { fail(e.message); return; }
611
+ close(); await refresh();
612
+ }
613
+ async function create(title) {
614
+ if (!project) { fail(tr('pick a project first')); return; }
615
+ let created;
616
+ try { created = await api('POST', '/api/tasks', { project, title, status: 'backlog' }); }
617
+ catch (e) { fail(e.message); return; }
618
+ await link(created.key);
619
+ openDrawer(created.key);
620
+ }
621
+ let activeIdx = -1;
622
+ const opts = () => [...results.querySelectorAll('.lp-opt')];
623
+ function highlight() {
624
+ const its = opts();
625
+ its.forEach((o, i) => o.classList.toggle('active', i === activeIdx));
626
+ if (activeIdx >= 0 && its[activeIdx]) its[activeIdx].scrollIntoView({ block: 'nearest' });
627
+ }
628
+ function render() {
629
+ const q = input.value.trim();
630
+ const ql = q.toLowerCase();
631
+ const matches = state.tasks
632
+ .filter((t) => t.key !== key && (!ql || t.title.toLowerCase().includes(ql) || t.key.toLowerCase().includes(ql)))
633
+ .slice(0, 8);
634
+ let html = matches.map((t) =>
635
+ `<div class="lp-opt" data-key="${t.key}"><span class="dot s-${t.status}"></span><span class="lp-key">${t.key}</span><span class="lp-title">${esc(t.title)}</span></div>`).join('');
636
+ if (q) html += `<div class="lp-opt lp-create" data-create="1">+ ${tr('Create')} "${esc(q)}"</div>`;
637
+ if (!html) html = `<div class="lp-empty">${tr('nothing found')}</div>`;
638
+ results.innerHTML = html;
639
+ results.querySelectorAll('.lp-opt[data-key]').forEach((o) => { o.onclick = () => link(o.dataset.key); });
640
+ const cr = results.querySelector('[data-create]');
641
+ if (cr) cr.onclick = () => create(input.value.trim());
642
+ activeIdx = opts().length ? 0 : -1;
643
+ highlight();
644
+ }
645
+ input.addEventListener('input', render);
646
+ input.addEventListener('keydown', (e) => {
647
+ const its = opts();
648
+ if (e.key === 'ArrowDown') { e.preventDefault(); activeIdx = Math.min(its.length - 1, activeIdx + 1); highlight(); }
649
+ else if (e.key === 'ArrowUp') { e.preventDefault(); activeIdx = Math.max(0, activeIdx - 1); highlight(); }
650
+ else if (e.key === 'Enter') {
651
+ e.preventDefault();
652
+ if (activeIdx >= 0 && its[activeIdx]) its[activeIdx].click();
653
+ else { const q = input.value.trim(); if (q) create(q); }
654
+ }
655
+ });
656
+ render();
657
+ input.focus();
658
+ }
659
+ export async function cancelTask(key) {
660
+ closeDrawerMenu(); closeCardMenu();
661
+ if (key && await styledConfirm(tr('Cancel the task? The issue will be closed as not planned.'), { okLabel: tr('Cancel the task') })) {
662
+ await api('PATCH', `/api/tasks/${seg(key)}`, { status: 'cancelled' });
663
+ if (key === state.drawerKey) closeDrawer();
664
+ await refresh();
665
+ }
666
+ }
667
+ async function duplicateTask(key) {
668
+ closeDrawerMenu(); closeCardMenu();
669
+ if (!key) return;
670
+ try {
671
+ const t = await api('POST', `/api/tasks/${seg(key)}/duplicate`);
672
+ await refresh();
673
+ if (t?.key) openDrawer(t.key);
674
+ } catch (e) { styledAlert(`${tr('Could not duplicate')}: ${e.message}`); }
675
+ }
676
+ export async function deleteTask(key) {
677
+ closeDrawerMenu(); closeCardMenu();
678
+ if (key && await styledConfirm(tr('Delete the task permanently (comments included)?'), { okLabel: tr('Delete'), danger: true })) {
679
+ await api('DELETE', `/api/tasks/${seg(key)}`);
680
+ if (key === state.drawerKey) closeDrawer();
681
+ await refresh();
682
+ }
683
+ }
684
+
685
+ function closeCardMenu() {
686
+ popLayer('card-menu');
687
+ $('card-menu')?.remove();
688
+ document.removeEventListener('click', cardMenuOutside);
689
+ document.removeEventListener('keydown', cardMenuKey);
690
+ }
691
+ function cardMenuOutside(e) { if (!e.target.closest('#card-menu')) closeCardMenu(); }
692
+ function cardMenuKey(e) { if (e.key === 'Escape') closeCardMenu(); }
693
+ export function openCardMenu(e, t) {
694
+ e.preventDefault();
695
+ closeCardMenu(); closeDrawerMenu();
696
+ pushLayer('card-menu', closeCardMenu);
697
+ const menu = document.createElement('div');
698
+ menu.className = 'drawer-menu-pop card-menu';
699
+ menu.id = 'card-menu';
700
+ document.body.appendChild(menu);
701
+ const cx = e.clientX; const cy = e.clientY;
702
+ const clamp = () => {
703
+ let left = cx; let top = cy;
704
+ if (left + menu.offsetWidth > window.innerWidth - 8) left = window.innerWidth - menu.offsetWidth - 8;
705
+ if (top + menu.offsetHeight > window.innerHeight - 8) top = window.innerHeight - menu.offsetHeight - 8;
706
+ menu.style.left = `${Math.max(8, left)}px`;
707
+ menu.style.top = `${Math.max(8, top)}px`;
708
+ };
709
+ const setField = async (body) => { await api('PATCH', `/api/tasks/${seg(t.key)}`, body); await refresh(); };
710
+ const backRow = `<div class="dm-item dm-back" data-back="1">${tr('‹ Back')}</div><div class="dm-sep"></div>`;
711
+
712
+ function renderMain() {
713
+ menu.innerHTML = `<div class="dm-item" data-act="status">${tr('Status')}<span class="dm-chev">›</span></div>`
714
+ + `<div class="dm-item" data-act="priority">${tr('Priority')}<span class="dm-chev">›</span></div>`
715
+ + `<div class="dm-item" data-act="labels">${tr('Labels')}<span class="dm-chev">›</span></div>`
716
+ + '<div class="dm-sep"></div>'
717
+ + `<div class="dm-item" data-act="subtask">${tr('Add subtask')}</div>`
718
+ + `<div class="dm-item" data-act="parent">${tr('Link parent task')}</div>`
719
+ + `<div class="dm-item" data-act="pin">${t.pinned ? tr('Unpin') : tr('Pin')}</div>`
720
+ + `<div class="dm-item" data-act="duplicate">${tr('Duplicate')}</div>`
721
+ + '<div class="dm-sep"></div>'
722
+ + `<div class="dm-item" data-act="copy">${tr('Copy link')}</div>`
723
+ + '<div class="dm-sep"></div>'
724
+ + `<div class="dm-item" data-act="cancel">${tr('Cancel task')}</div>`
725
+ + `<div class="dm-item dm-danger" data-act="delete">${tr('Delete')}</div>`;
726
+ const it = (a) => menu.querySelector(`[data-act="${a}"]`);
727
+ it('status').onclick = (ev) => { ev.stopPropagation(); renderSub('status'); };
728
+ it('priority').onclick = (ev) => { ev.stopPropagation(); renderSub('priority'); };
729
+ it('labels').onclick = (ev) => { ev.stopPropagation(); renderSub('labels'); };
730
+ it('subtask').onclick = () => { closeCardMenu(); openLinkPicker('child', t.key); };
731
+ it('parent').onclick = () => { closeCardMenu(); openLinkPicker('parent', t.key); };
732
+ it('pin').onclick = () => togglePinTask(t.key);
733
+ it('duplicate').onclick = () => { closeCardMenu(); duplicateTask(t.key); };
734
+ it('copy').onclick = () => { closeCardMenu(); copyTaskLink(t); };
735
+ it('cancel').onclick = () => cancelTask(t.key);
736
+ it('delete').onclick = () => deleteTask(t.key);
737
+ clamp();
738
+ }
739
+ function wireBack() { menu.querySelector('[data-back]').onclick = (ev) => { ev.stopPropagation(); renderMain(); }; }
740
+ function renderSub(kind) {
741
+ if (kind === 'status') {
742
+ menu.innerHTML = backRow + ALL_STATUSES.map(([v, lab]) => {
743
+ const dis = Boolean(moveBlocked(t.status, v, { manual: taskIsNoclaude(t) }));
744
+ return `<div class="cm-opt${v === t.status ? ' active' : ''}${dis ? ' disabled' : ''}" data-v="${v}"><span class="dot s-${v}"></span>${lab}</div>`;
745
+ }).join('');
746
+ menu.querySelectorAll('.cm-opt:not(.disabled)').forEach((o) => { o.onclick = async (ev) => { ev.stopPropagation(); const v = o.dataset.v; closeCardMenu(); if (v !== t.status) await setField({ status: v }); }; });
747
+ wireBack(); clamp();
748
+ } else if (kind === 'priority') {
749
+ menu.innerHTML = backRow + PRI_LEVELS.map(([v, lab]) => `<div class="cm-opt${v === t.priority ? ' active' : ''}" data-v="${v}">${lab}</div>`).join('');
750
+ menu.querySelectorAll('.cm-opt').forEach((o) => { o.onclick = async (ev) => { ev.stopPropagation(); const v = Number(o.dataset.v); closeCardMenu(); if (v !== t.priority) await setField({ priority: v }); }; });
751
+ wireBack(); clamp();
752
+ } else {
753
+ let cur = [...(t.labels || [])];
754
+ const drawLabels = () => {
755
+ menu.innerHTML = backRow + LABEL_SELECTABLE.map((name) =>
756
+ `<div class="cm-opt lbl-row${cur.includes(name) ? ' on' : ''}" data-name="${name}"><span class="lbl-dot" style="width:9px;height:9px;border-radius:50%;background:${LABEL_COLORS[name]}"></span>${esc(name)}${cur.includes(name) ? '<span class="dm-chev">✓</span>' : ''}</div>`).join('');
757
+ menu.querySelectorAll('.lbl-row').forEach((o) => { o.onclick = async (ev) => { ev.stopPropagation(); const n = o.dataset.name; cur = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n]; t.labels = cur; drawLabels(); await setField({ labels: cur }); }; });
758
+ wireBack(); clamp();
759
+ };
760
+ drawLabels();
761
+ }
762
+ }
763
+ renderMain();
764
+ requestAnimationFrame(() => menu.classList.add('open'));
765
+ setTimeout(() => { document.addEventListener('click', cardMenuOutside); document.addEventListener('keydown', cardMenuKey); }, 0);
766
+ }
767
+
768
+ async function copyTaskLink(t) {
769
+ await copyText(t.gh_issue_url || t.key);
770
+ }
771
+
772
+ function closeReturnPop() {
773
+ popLayer('return-pop');
774
+ $('return-pop')?.remove();
775
+ document.removeEventListener('click', returnPopOutside);
776
+ }
777
+ function returnPopOutside(e) {
778
+ if (!e.target.closest('#return-pop') && e.target.id !== 'd-return-icon') closeReturnPop();
779
+ }
780
+ export function openReturnPop() {
781
+ if ($('return-pop')) { closeReturnPop(); return; }
782
+ if (!state.drawerKey) return;
783
+ pushLayer('return-pop', closeReturnPop);
784
+ const pop = document.createElement('div');
785
+ pop.className = 'return-pop';
786
+ pop.id = 'return-pop';
787
+ pop.innerHTML = `<input class="return-pop-input" placeholder="${tr('What is wrong? (goes into the task)')}">`
788
+ + `<button class="return-pop-btn btn-ghost">${tr('↩ Send back to work')}</button>`;
789
+ document.body.appendChild(pop);
790
+ const r = $('d-return-icon').getBoundingClientRect();
791
+ pop.style.top = `${r.bottom + 6}px`;
792
+ pop.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
793
+ requestAnimationFrame(() => pop.classList.add('open'));
794
+ const input = pop.querySelector('.return-pop-input');
795
+ pop.querySelector('.return-pop-btn').onclick = () => doReturn(input.value.trim());
796
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doReturn(input.value.trim()); });
797
+ setTimeout(() => { document.addEventListener('click', returnPopOutside); input.focus(); }, 0);
798
+ }
799
+ async function doReturn(reason) {
800
+ if (!state.drawerKey) return;
801
+ const key = state.drawerKey;
802
+ if (reason) await api('POST', `/api/tasks/${seg(key)}/comments`, { body: `${tr('Send back')}: ${reason}`, author: 'me' });
803
+ await api('PATCH', `/api/tasks/${seg(key)}`, { status: 'todo' });
804
+ closeReturnPop();
805
+ await refresh();
806
+ openDrawer(key);
807
+ }
808
+
809
+ async function refreshDrawerFields(t) {
810
+ if (autosaveTimer || drawerLocked) return;
811
+ const active = document.activeElement;
812
+ const title = $('d-title');
813
+ const desc = $('d-desc');
814
+ if ('description' in t) {
815
+ if (desc !== active && desc.value !== (t.description || '')) { desc.value = t.description || ''; autoGrow(desc); }
816
+ } else if (desc !== active && (t.preview || '') !== desc.value.slice(0, 180)) {
817
+ try { t = await api('GET', `/api/tasks/${seg(t.key)}`, undefined, { quiet: true }); } catch { return; }
818
+ if (autosaveTimer || document.activeElement === desc) return;
819
+ if (desc.value !== (t.description || '')) { desc.value = t.description || ''; autoGrow(desc); }
820
+ }
821
+ if (title !== active && title.value !== (t.title || '')) { title.value = t.title || ''; autoGrow(title); }
822
+ if (Number(selVal('d-priority')) !== t.priority) buildPrioritySelect(t.priority);
823
+ const labels = t.labels || [];
824
+ if (!sameLabels(drawerLabels, labels)) { drawerLabels = [...labels]; renderDrawerLabels(); }
825
+ if (drawerSnapshot) Object.assign(drawerSnapshot, changedFields({
826
+ title: t.title || '', priority: t.priority, labels: [...labels], status: t.status,
827
+ ...('description' in t ? { description: t.description || '' } : {}),
828
+ }, drawerSnapshot));
829
+ }
830
+
831
+ export async function syncOpenDrawerStatus() {
832
+ if (!state.drawerKey) return;
833
+ let t = state.tasks.find((x) => x.key === state.drawerKey);
834
+ if (!t) {
835
+ try {
836
+ t = await api('GET', `/api/tasks/${seg(state.drawerKey)}`, undefined, { quiet: true });
837
+ } catch (e) {
838
+ if (e?.status === 404) { styledAlert(tr('This task no longer exists — it was deleted.')); closeDrawer(); }
839
+ return;
840
+ }
841
+ }
842
+ if (!t) return;
843
+ await refreshDrawerFields(t);
844
+ if (!DrawerSync.shouldSyncDrawerStatus(drawerStatus, selVal('d-status'), t.status)) return;
845
+ drawerStatus = t.status;
846
+ buildStatusSelect(t.status, false, drawerLabels.includes('noclaude'));
847
+ $('d-return').classList.toggle('hidden', t.status !== 'review');
848
+ $('d-return-icon').classList.toggle('hidden', !['review', 'done'].includes(t.status));
849
+ $('d-accept-btn').classList.toggle('hidden', t.status !== 'review');
850
+ const willLock = (t.status === 'doing' || t.status === 'deploy') && !taskIsNoclaude(t);
851
+ if (willLock && autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; await doAutosave(); }
852
+ setDrawerLock(willLock);
853
+ }
854
+
855
+ let autosaveTimer = null;
856
+ function newTaskHasContent() {
857
+ return $('d-title').value.trim() || $('d-desc').value.trim() || pendingAttachments.length;
858
+ }
859
+ export function scheduleAutosave() {
860
+ if (!state.drawerKey && !createInFlight && newTaskHasContent()) { doAutosave(); return; }
861
+ clearTimeout(autosaveTimer);
862
+ autosaveTimer = setTimeout(doAutosave, 450);
863
+ }
864
+
865
+ export async function doAutosave() {
866
+ try {
867
+ return await doAutosaveOnce();
868
+ } catch (e) {
869
+ if (e?.offline) { noteUnsaved(doAutosaveOnce); return undefined; }
870
+ throw e;
871
+ }
872
+ }
873
+ async function doAutosaveOnce() {
874
+ clearTimeout(autosaveTimer); autosaveTimer = null;
875
+ if (drawerLocked || $('drawer').classList.contains('hidden')) return;
876
+ const title = $('d-title').value.trim();
877
+ const description = $('d-desc').value;
878
+ const common = {
879
+ description,
880
+ priority: Number(selVal('d-priority')),
881
+ labels: drawerLabels,
882
+ status: selVal('d-status'),
883
+ };
884
+ if (state.drawerKey) {
885
+ const full = title ? { ...common, title } : common;
886
+ const patch = drawerSnapshot ? changedFields(full, drawerSnapshot) : full;
887
+ if (!Object.keys(patch).length) return;
888
+ await api('PATCH', `/api/tasks/${seg(state.drawerKey)}`, patch);
889
+ if (drawerSnapshot) Object.assign(drawerSnapshot, patch);
890
+ return;
891
+ }
892
+ if (!title && !description.trim() && !pendingAttachments.length) return;
893
+ if (createInFlight) return;
894
+ const project = state.drawerProject || selVal('d-project');
895
+ if (!project) return;
896
+ const wasOpenOnNew = !$('drawer').classList.contains('hidden') && !state.drawerKey;
897
+ const pend = [...pendingAttachments];
898
+ let created;
899
+ createInFlight = true;
900
+ try {
901
+ created = await api('POST', '/api/tasks', { project, title, ...common });
902
+ } finally {
903
+ createInFlight = false;
904
+ }
905
+ if (wasOpenOnNew && !state.drawerKey) {
906
+ state.drawerKey = created.key;
907
+ drawerStatus = created.status;
908
+ setDrawerId(created.key, created.project);
909
+ $('d-project').classList.add('hidden');
910
+ $('comments-block').classList.remove('hidden');
911
+ renderComments(created.key);
912
+ $('drawer-menu').classList.remove('hidden');
913
+ renderChecklist([]);
914
+ }
915
+ for (const p of pend) await uploadAttachment(p.blob, created.key);
916
+ clearPending();
917
+ if (wasOpenOnNew) { setScrollToNewCardId(created.id); setTimeout(() => setScrollToNewCardId(null), 900); }
918
+ await refresh();
919
+ }