@polderlabs/openkan 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
// OpenKan — task detail view (M7/M8/M9 + M10/M11 + M13 keyboard nav).
|
|
2
|
+
// window.OpenKanTaskView = { open(taskId), close(), getCurrentTaskId() }
|
|
3
|
+
//
|
|
4
|
+
// Owns:
|
|
5
|
+
// - #task-view shell + header + "needs you" banner
|
|
6
|
+
// - assignees row, agent progress timeline, last-activity timestamp
|
|
7
|
+
// - right-side comments panel + inputs history
|
|
8
|
+
// - delegates rendered HTML → window.OpenKanMdxViewer.mount(...)
|
|
9
|
+
// - archive / restore / move-to-next-column actions
|
|
10
|
+
// - SSE subscriptions scoped to this task (filters by taskId)
|
|
11
|
+
|
|
12
|
+
(() => {
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const { api, on } = window.OpenKanAPI;
|
|
16
|
+
|
|
17
|
+
// Categories that the server may report; must match what the cards + filter
|
|
18
|
+
// bar use (see web/app.js).
|
|
19
|
+
const CATEGORIES = new Set([
|
|
20
|
+
"frontend", "backend", "infra", "docs",
|
|
21
|
+
"test", "design", "data", "security", "task",
|
|
22
|
+
]);
|
|
23
|
+
const PRIORITY_META = {
|
|
24
|
+
urgent: { code: "P0", label: "Urgent" },
|
|
25
|
+
high: { code: "P1", label: "High" },
|
|
26
|
+
normal: { code: "P2", label: "Normal" },
|
|
27
|
+
low: { code: "P3", label: "Low" },
|
|
28
|
+
};
|
|
29
|
+
// Same column progression as the board.
|
|
30
|
+
const COLUMN_ORDER = ["backlog", "todo", "doing", "review", "done"];
|
|
31
|
+
const COLUMN_LABEL = {
|
|
32
|
+
backlog: "Backlog", todo: "To Do", doing: "In Progress", review: "Review", done: "Done",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let currentTaskId = null;
|
|
36
|
+
/** @type {Array<() => void>} */
|
|
37
|
+
let unsubs = [];
|
|
38
|
+
|
|
39
|
+
function el(tag, cls, props = {}) {
|
|
40
|
+
const e = document.createElement(tag);
|
|
41
|
+
if (cls) e.className = cls;
|
|
42
|
+
for (const [k, v] of Object.entries(props)) {
|
|
43
|
+
if (k === "text") e.textContent = v;
|
|
44
|
+
else if (k === "html") e.innerHTML = v;
|
|
45
|
+
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
|
46
|
+
else e.setAttribute(k, v);
|
|
47
|
+
}
|
|
48
|
+
return e;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function effectiveState(t) {
|
|
52
|
+
return t.state ?? t.status ?? "idle";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build a clickable tag chip. When the user clicks one in the task view,
|
|
56
|
+
// we close the view, set the tag as a board filter, and re-render.
|
|
57
|
+
function makeTagChip(tag) {
|
|
58
|
+
const lower = String(tag).toLowerCase();
|
|
59
|
+
const isCategory = CATEGORIES.has(lower);
|
|
60
|
+
const cls = `tag-chip t-${lower}` + (isCategory ? " category" : "");
|
|
61
|
+
const chip = el("button", cls, { type: "button", text: lower, title: `Filter board by ${lower}` });
|
|
62
|
+
if (isCategory) chip.classList.add(`c-${lower}`);
|
|
63
|
+
chip.addEventListener("click", (e) => {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
const board = window.OpenKanBoard;
|
|
67
|
+
if (board) board.setTagFilter(lower);
|
|
68
|
+
window.OpenKanTaskView.close();
|
|
69
|
+
});
|
|
70
|
+
return chip;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shortDate(iso) {
|
|
74
|
+
if (!iso) return "";
|
|
75
|
+
const d = new Date(iso);
|
|
76
|
+
if (isNaN(d.getTime())) return "";
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const sameYear = d.getFullYear() === now.getFullYear();
|
|
79
|
+
return d.toLocaleString(undefined, {
|
|
80
|
+
month: "short",
|
|
81
|
+
day: "numeric",
|
|
82
|
+
hour: "2-digit",
|
|
83
|
+
minute: "2-digit",
|
|
84
|
+
year: sameYear ? undefined : "numeric",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function relativeTime(iso) {
|
|
89
|
+
if (!iso) return "";
|
|
90
|
+
const t = new Date(iso).getTime();
|
|
91
|
+
if (isNaN(t)) return "";
|
|
92
|
+
const delta = Date.now() - t;
|
|
93
|
+
const sec = Math.floor(delta / 1000);
|
|
94
|
+
if (sec < 5) return "just now";
|
|
95
|
+
if (sec < 60) return `${sec}s ago`;
|
|
96
|
+
const min = Math.floor(sec / 60);
|
|
97
|
+
if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
|
|
98
|
+
const hr = Math.floor(min / 60);
|
|
99
|
+
if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
|
|
100
|
+
const day = Math.floor(hr / 24);
|
|
101
|
+
if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
|
|
102
|
+
return new Date(iso).toLocaleDateString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function avatarColorFor(seed) {
|
|
106
|
+
let h = 0;
|
|
107
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
|
108
|
+
const hue = Math.abs(h) % 360;
|
|
109
|
+
return `hsl(${hue} 60% 45%)`;
|
|
110
|
+
}
|
|
111
|
+
function initialsFor(name) {
|
|
112
|
+
if (!name) return "?";
|
|
113
|
+
const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
|
|
114
|
+
if (parts.length === 0) return "?";
|
|
115
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
116
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Inline editing ─────────────────────────────────────────────────────────
|
|
120
|
+
// Wire a contenteditable element (title or description) to persist ONLY on
|
|
121
|
+
// explicit user actions. The previous input-debounced save fired after
|
|
122
|
+
// every typing pause, which threw the user out of the edit context before
|
|
123
|
+
// they finished a sentence. Now:
|
|
124
|
+
// - Input events toggle the dirty indicator only — never save.
|
|
125
|
+
// - Enter commits and exits (Shift+Enter inserts a newline in desc).
|
|
126
|
+
// - Ctrl/Cmd+Enter always commits and exits, regardless of shift.
|
|
127
|
+
// - Escape cancels and exits — text reverts to lastSaved, no PATCH.
|
|
128
|
+
// - Blur starts a quiet-period timer (DEFAULT_INLINE_EDIT_BLUR_QUIET_MS);
|
|
129
|
+
// if focus returns or input fires before the timer expires, the save
|
|
130
|
+
// is cancelled. This still saves when the user truly walks away
|
|
131
|
+
// (alt-tab, click elsewhere) but ignores focus→blur→focus loops.
|
|
132
|
+
// - A visible "Save" button commits explicitly.
|
|
133
|
+
const DEFAULT_INLINE_EDIT_BLUR_QUIET_MS = 1500;
|
|
134
|
+
function wireInlineEdit(el_, task, field, opts = {}) {
|
|
135
|
+
if (!el_) return () => {};
|
|
136
|
+
const QUIET_MS = Number.isFinite(opts.blurQuietMs) ? opts.blurQuietMs : DEFAULT_INLINE_EDIT_BLUR_QUIET_MS;
|
|
137
|
+
let blurTimer = null;
|
|
138
|
+
let lastSaved = String(el_.dataset?.original ?? el_.textContent ?? "");
|
|
139
|
+
let doneBtn = null;
|
|
140
|
+
|
|
141
|
+
const disarmBlurTimer = () => {
|
|
142
|
+
if (blurTimer) { clearTimeout(blurTimer); blurTimer = null; }
|
|
143
|
+
};
|
|
144
|
+
const armBlurTimer = (setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout) => {
|
|
145
|
+
disarmBlurTimer();
|
|
146
|
+
blurTimer = setTimeoutImpl(() => {
|
|
147
|
+
blurTimer = null;
|
|
148
|
+
flush();
|
|
149
|
+
}, QUIET_MS);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const flush = async () => {
|
|
153
|
+
disarmBlurTimer();
|
|
154
|
+
const newVal = String(el_.textContent || "").trim();
|
|
155
|
+
if (newVal === lastSaved) return;
|
|
156
|
+
if (field === "title" && !newVal) {
|
|
157
|
+
// Title is required — revert on empty and stay in edit mode.
|
|
158
|
+
el_.textContent = lastSaved;
|
|
159
|
+
el_.classList.remove("dirty");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await api("PATCH", `/api/tasks/${task.id}`, { [field]: newVal });
|
|
164
|
+
lastSaved = newVal;
|
|
165
|
+
el_.classList.remove("dirty");
|
|
166
|
+
if (window.OpenKanSettings?.showToast) {
|
|
167
|
+
window.OpenKanSettings.showToast(`✓ Saved`, "success");
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
el_.classList.add("dirty");
|
|
171
|
+
if (window.OpenKanSettings?.showToast) {
|
|
172
|
+
window.OpenKanSettings.showToast(`Save failed: ${err.message}`, "error");
|
|
173
|
+
} else {
|
|
174
|
+
alert(`Save failed: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const cancelEdits = () => {
|
|
180
|
+
disarmBlurTimer();
|
|
181
|
+
el_.textContent = lastSaved;
|
|
182
|
+
el_.classList.remove("dirty");
|
|
183
|
+
el_.blur();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const onInput = () => {
|
|
187
|
+
el_.classList.toggle("dirty", String(el_.textContent || "").trim() !== lastSaved);
|
|
188
|
+
el_.classList.remove("placeholder");
|
|
189
|
+
// Any new keystroke cancels a pending blur-window save — the user
|
|
190
|
+
// clearly hasn't finished walking away.
|
|
191
|
+
disarmBlurTimer();
|
|
192
|
+
};
|
|
193
|
+
const onKeydown = (e) => {
|
|
194
|
+
// Ctrl/Cmd+Enter is always an explicit save regardless of shift state.
|
|
195
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
disarmBlurTimer();
|
|
198
|
+
flush();
|
|
199
|
+
el_.blur();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (e.key === "Escape") {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
cancelEdits();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (e.key === "Enter" && !(field === "description" && e.shiftKey)) {
|
|
208
|
+
// Plain Enter commits — for a title it's the only way to enter
|
|
209
|
+
// a newline-free commit; for a description Shift+Enter inserts a
|
|
210
|
+
// newline instead.
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
disarmBlurTimer();
|
|
213
|
+
flush();
|
|
214
|
+
el_.blur();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const onBlur = () => {
|
|
218
|
+
// Defer the actual save: a focus→blur→focus loop (e.g. tabbing
|
|
219
|
+
// between title and description) must not trigger persistence.
|
|
220
|
+
armBlurTimer();
|
|
221
|
+
};
|
|
222
|
+
const onFocus = () => {
|
|
223
|
+
// Coming back into the field cancels any pending quiet-period save.
|
|
224
|
+
disarmBlurTimer();
|
|
225
|
+
el_.classList.add("editing");
|
|
226
|
+
};
|
|
227
|
+
const onFocusout = () => {
|
|
228
|
+
el_.classList.remove("editing");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
el_.addEventListener("input", onInput);
|
|
232
|
+
el_.addEventListener("keydown", onKeydown);
|
|
233
|
+
el_.addEventListener("blur", onBlur);
|
|
234
|
+
el_.addEventListener("focus", onFocus);
|
|
235
|
+
el_.addEventListener("focusout", onFocusout);
|
|
236
|
+
|
|
237
|
+
// Explicit "Save" affordance so the user always has a visible commit
|
|
238
|
+
// gesture independent of keyboard timing.
|
|
239
|
+
doneBtn = el("button", "inline-edit-done", {
|
|
240
|
+
type: "button",
|
|
241
|
+
text: "Save",
|
|
242
|
+
title: "Save changes (Ctrl/Cmd+Enter)",
|
|
243
|
+
"aria-label": "Save inline edit",
|
|
244
|
+
});
|
|
245
|
+
doneBtn.hidden = true;
|
|
246
|
+
// Prevent the button's mousedown from stealing focus; we drive the
|
|
247
|
+
// save+blur ourselves so the input never loses focus during typing.
|
|
248
|
+
doneBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
249
|
+
doneBtn.addEventListener("click", (e) => {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
disarmBlurTimer();
|
|
252
|
+
flush();
|
|
253
|
+
el_.blur();
|
|
254
|
+
});
|
|
255
|
+
if (el_.parentElement) el_.parentElement.appendChild(doneBtn);
|
|
256
|
+
const showDoneBtn = () => { if (doneBtn) doneBtn.hidden = false; };
|
|
257
|
+
const hideDoneBtn = () => { if (doneBtn) doneBtn.hidden = true; };
|
|
258
|
+
el_.addEventListener("focus", showDoneBtn);
|
|
259
|
+
// Hide on the next tick so a focus→blur flicker doesn't toggle the
|
|
260
|
+
// button visibly during quick focus shifts.
|
|
261
|
+
el_.addEventListener("blur", () => { setTimeout(hideDoneBtn, 0); });
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
disarmBlurTimer();
|
|
265
|
+
el_.removeEventListener("input", onInput);
|
|
266
|
+
el_.removeEventListener("keydown", onKeydown);
|
|
267
|
+
el_.removeEventListener("blur", onBlur);
|
|
268
|
+
el_.removeEventListener("focus", onFocus);
|
|
269
|
+
el_.removeEventListener("focusout", onFocusout);
|
|
270
|
+
el_.removeEventListener("focus", showDoneBtn);
|
|
271
|
+
if (doneBtn && doneBtn.parentElement) doneBtn.parentElement.removeChild(doneBtn);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Exported for regression tests — see tests/inline-edit.test.mts.
|
|
276
|
+
// Not part of the public TaskView surface used by app.js.
|
|
277
|
+
if (typeof window !== "undefined") {
|
|
278
|
+
window.OpenKanInlineEdit = { wire: wireInlineEdit };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Right-click context menu on the MDX slot ──────────────────────────────
|
|
282
|
+
// The MDX viewer is shared with the per-task card right-click menu, so we
|
|
283
|
+
// expose the menu renderer at window.OpenKanMenu (set by app.js). If it
|
|
284
|
+
// isn't available (e.g. tests), this is a no-op.
|
|
285
|
+
function onMdxContextMenu(ev) {
|
|
286
|
+
const menu = window.OpenKanMenu;
|
|
287
|
+
if (!menu) return;
|
|
288
|
+
// Don't hijack the menu on input/textarea/buttons.
|
|
289
|
+
const tag = (ev.target.tagName || "").toLowerCase();
|
|
290
|
+
if (tag === "input" || tag === "textarea" || tag === "button" || tag === "a") return;
|
|
291
|
+
if (ev.target.isContentEditable) return;
|
|
292
|
+
|
|
293
|
+
ev.preventDefault();
|
|
294
|
+
ev.stopPropagation();
|
|
295
|
+
|
|
296
|
+
// Pick the closest block (or use the MDX root if user right-clicked the
|
|
297
|
+
// empty area).
|
|
298
|
+
const block = ev.target.closest(".mdx-block[data-block-id]");
|
|
299
|
+
const blockId = block?.getAttribute("data-block-id") || null;
|
|
300
|
+
const line = block?.getAttribute("data-line") || null;
|
|
301
|
+
|
|
302
|
+
const items = [
|
|
303
|
+
{ label: "Copy", action: () => {
|
|
304
|
+
const sel = window.getSelection()?.toString() || block?.textContent || "";
|
|
305
|
+
if (!sel) return;
|
|
306
|
+
navigator.clipboard.writeText(sel).catch(() => {});
|
|
307
|
+
}},
|
|
308
|
+
{ label: "Copy as Markdown", action: () => {
|
|
309
|
+
// Render the HTML to plain markdown-ish text via textContent of
|
|
310
|
+
// each block, preserving newlines. Lightweight — the task view
|
|
311
|
+
// surfaces this for "paste into chat" workflows.
|
|
312
|
+
const target = block || ev.target.closest(".mdx-slot");
|
|
313
|
+
if (!target) return;
|
|
314
|
+
const text = target.innerText || target.textContent || "";
|
|
315
|
+
navigator.clipboard.writeText(text).catch(() => {});
|
|
316
|
+
}},
|
|
317
|
+
{ label: "Open in new tab", action: () => {
|
|
318
|
+
const url = `${location.origin}${location.pathname}#tab=tasks&taskId=${currentTaskId}`;
|
|
319
|
+
window.open(url, "_blank", "noopener");
|
|
320
|
+
}},
|
|
321
|
+
];
|
|
322
|
+
if (blockId) {
|
|
323
|
+
items.push({ kind: "divider" });
|
|
324
|
+
items.push({
|
|
325
|
+
label: "Add comment here",
|
|
326
|
+
action: () => {
|
|
327
|
+
// Synthesize a click on the block so the mdx-viewer opens its composer.
|
|
328
|
+
try { block.click(); } catch {}
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
const ta = document.querySelector(".mdx-block-comment-composer .composer-textarea");
|
|
331
|
+
if (ta) try { ta.focus(); } catch {}
|
|
332
|
+
}, 80);
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
items.push({
|
|
336
|
+
label: `Copy block hash (${String(blockId).slice(0, 8)}…)`,
|
|
337
|
+
action: () => {
|
|
338
|
+
navigator.clipboard.writeText(String(blockId)).catch(() => {});
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
if (line) {
|
|
342
|
+
items.push({
|
|
343
|
+
label: `Copy line number (${line})`,
|
|
344
|
+
action: () => {
|
|
345
|
+
navigator.clipboard.writeText(String(line)).catch(() => {});
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
menu.showAt(items, ev);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Render header ──────────────────────────────────────────────────────────
|
|
354
|
+
// Layout (top to bottom):
|
|
355
|
+
// • Quick-actions toolbar (right-aligned icon buttons)
|
|
356
|
+
// • Back button row + <h1 contenteditable="true"> title + status pills
|
|
357
|
+
// • <div contenteditable="true"> description
|
|
358
|
+
function renderHeader(root, task) {
|
|
359
|
+
root.innerHTML = "";
|
|
360
|
+
const header = el("header", "task-header");
|
|
361
|
+
|
|
362
|
+
// Quick-actions toolbar uses compact text labels so every action remains
|
|
363
|
+
// clear without relying on emoji or a separate icon dependency.
|
|
364
|
+
const actions = el("div", "task-actions");
|
|
365
|
+
const mkIconBtn = (glyph, label, onClick, danger) => {
|
|
366
|
+
const b = el("button", "task-action-btn" + (danger ? " danger" : ""), {
|
|
367
|
+
type: "button",
|
|
368
|
+
text: glyph,
|
|
369
|
+
title: label,
|
|
370
|
+
"aria-label": label,
|
|
371
|
+
});
|
|
372
|
+
b.addEventListener("click", onClick);
|
|
373
|
+
return b;
|
|
374
|
+
};
|
|
375
|
+
actions.append(mkIconBtn("Discuss", "Discuss this task in chat", () => {
|
|
376
|
+
window.OpenKanTaskView.close();
|
|
377
|
+
void window.OpenKanChatSidebar?.mentionTask?.(task);
|
|
378
|
+
}));
|
|
379
|
+
// Copy task ID.
|
|
380
|
+
actions.append(mkIconBtn("ID", "Copy ID", async () => {
|
|
381
|
+
try {
|
|
382
|
+
await navigator.clipboard.writeText(String(task.id || ""));
|
|
383
|
+
window.OpenKanSettings?.showToast?.("Task ID copied", "success");
|
|
384
|
+
} catch (_) {
|
|
385
|
+
alert(`Task ID: ${task.id}`);
|
|
386
|
+
}
|
|
387
|
+
}));
|
|
388
|
+
// Copy markdown link.
|
|
389
|
+
actions.append(mkIconBtn("Link", "Copy markdown link", async () => {
|
|
390
|
+
const md = `[${task.title || task.id}](.ok/tasks/${task.id}/task.mdx)`;
|
|
391
|
+
try {
|
|
392
|
+
await navigator.clipboard.writeText(md);
|
|
393
|
+
window.OpenKanSettings?.showToast?.("Markdown link copied", "success");
|
|
394
|
+
} catch (_) {
|
|
395
|
+
alert(md);
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
// Open in new tab — same URL but in a fresh tab so the user can keep
|
|
399
|
+
// their context (e.g. side-by-side comparison).
|
|
400
|
+
actions.append(mkIconBtn("Open", "Open in new tab", () => {
|
|
401
|
+
const url = `${location.origin}${location.pathname}#tab=tasks&taskId=${task.id}`;
|
|
402
|
+
window.open(url, "_blank", "noopener");
|
|
403
|
+
}));
|
|
404
|
+
// Edit — focuses the inline-editable title (the footer also has an Edit
|
|
405
|
+
// button; this one is just closer to the cursor for keyboard users).
|
|
406
|
+
actions.append(mkIconBtn("Edit", "Edit title & description", () => {
|
|
407
|
+
const titleEl = document.querySelector(".task-title");
|
|
408
|
+
if (titleEl && titleEl.isContentEditable) {
|
|
409
|
+
try { titleEl.focus(); } catch {}
|
|
410
|
+
const range = document.createRange();
|
|
411
|
+
range.selectNodeContents(titleEl);
|
|
412
|
+
const sel = window.getSelection();
|
|
413
|
+
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (window.OpenKanEditTask?.open) window.OpenKanEditTask.open(task.id);
|
|
417
|
+
}));
|
|
418
|
+
// Archive / Restore.
|
|
419
|
+
if (task.archived) {
|
|
420
|
+
actions.append(mkIconBtn("Restore", "Restore from archive", async () => {
|
|
421
|
+
try { await api("POST", `/api/tasks/${task.id}/restore`); }
|
|
422
|
+
catch (err) { alert(`Restore failed: ${err.message}`); }
|
|
423
|
+
}));
|
|
424
|
+
} else {
|
|
425
|
+
actions.append(mkIconBtn("Archive", "Archive", () => {
|
|
426
|
+
if (!confirm(`Archive "${task.title}"?`)) return;
|
|
427
|
+
api("POST", `/api/tasks/${task.id}/archive`).catch((err) =>
|
|
428
|
+
alert(`Archive failed: ${err.message}`),
|
|
429
|
+
);
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
// Delete (always last in the toolbar, danger styling).
|
|
433
|
+
actions.append(mkIconBtn("Delete", "Delete task", () => {
|
|
434
|
+
if (!confirm(`Delete task "${task.title}"? This cannot be undone.`)) return;
|
|
435
|
+
api("DELETE", `/api/tasks/${task.id}`)
|
|
436
|
+
.then(() => window.OpenKanTaskView.close())
|
|
437
|
+
.catch((err) => alert(`Delete failed: ${err.message}`));
|
|
438
|
+
}, true));
|
|
439
|
+
header.append(actions);
|
|
440
|
+
|
|
441
|
+
const back = el("button", "btn", { text: "← Back", type: "button" });
|
|
442
|
+
back.addEventListener("click", () => window.OpenKanTaskView.close());
|
|
443
|
+
header.append(back);
|
|
444
|
+
|
|
445
|
+
const titleWrap = el("div", "task-title-wrap");
|
|
446
|
+
const title = el("h1", "task-title", {
|
|
447
|
+
contenteditable: "true",
|
|
448
|
+
spellcheck: "false",
|
|
449
|
+
"data-original": task.title || "",
|
|
450
|
+
title: "Click to edit title",
|
|
451
|
+
"aria-label": "Task title (click to edit)",
|
|
452
|
+
});
|
|
453
|
+
title.textContent = task.title || "(untitled)";
|
|
454
|
+
titleWrap.append(title);
|
|
455
|
+
|
|
456
|
+
header.append(titleWrap);
|
|
457
|
+
|
|
458
|
+
// Right-aligned status / priority pills (visually anchored to the title row).
|
|
459
|
+
const statusCol = el("div", "task-status-col");
|
|
460
|
+
const state = effectiveState(task);
|
|
461
|
+
const statePill = el("span", `pill state-pill state-${state}`, { text: state });
|
|
462
|
+
statusCol.append(statePill);
|
|
463
|
+
const pri = PRIORITY_META[task.priority];
|
|
464
|
+
if (pri && task.priority) {
|
|
465
|
+
const pCls = `priority-${task.priority}`;
|
|
466
|
+
statusCol.append(el("span", `pill ${pCls}`, {
|
|
467
|
+
text: `${pri.code} ${pri.label}`,
|
|
468
|
+
title: `priority: ${pri.label}`,
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
if (task.archived) statusCol.append(el("span", "pill archived-pill", { text: "archived" }));
|
|
472
|
+
header.append(statusCol);
|
|
473
|
+
|
|
474
|
+
// Description — also inline-editable. Lives directly under the header so
|
|
475
|
+
// title+description form the natural top of the task.
|
|
476
|
+
const desc = el("div", "task-description", {
|
|
477
|
+
contenteditable: "true",
|
|
478
|
+
spellcheck: "true",
|
|
479
|
+
"data-original": task.description || "",
|
|
480
|
+
title: "Click to edit description",
|
|
481
|
+
"aria-label": "Task description (click to edit)",
|
|
482
|
+
});
|
|
483
|
+
desc.textContent = task.description || "";
|
|
484
|
+
if (!task.description) desc.classList.add("placeholder");
|
|
485
|
+
header.append(desc);
|
|
486
|
+
|
|
487
|
+
root.append(header);
|
|
488
|
+
|
|
489
|
+
// Wire inline-edit save on title + description.
|
|
490
|
+
wireInlineEdit(title, task, "title");
|
|
491
|
+
wireInlineEdit(desc, task, "description");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Render "Needs you" banner ─────────────────────────────────────────────
|
|
495
|
+
function renderBanner(root, task, pendingInput) {
|
|
496
|
+
root.innerHTML = "";
|
|
497
|
+
if (effectiveState(task) !== "waiting-for-input" || !pendingInput) return;
|
|
498
|
+
|
|
499
|
+
const banner = el("section", "needs-you-banner");
|
|
500
|
+
const cat = String(task.category || "").toLowerCase();
|
|
501
|
+
if (cat && CATEGORIES.has(cat)) banner.classList.add(`cat-${cat}`);
|
|
502
|
+
banner.append(el("div", "banner-title", { text: "Needs your input" }));
|
|
503
|
+
banner.append(el("div", "banner-question", { text: pendingInput.question || "" }));
|
|
504
|
+
|
|
505
|
+
const form = el("form", "banner-form");
|
|
506
|
+
let inputEl;
|
|
507
|
+
switch (pendingInput.type) {
|
|
508
|
+
case "choice": {
|
|
509
|
+
inputEl = el("div", "banner-options");
|
|
510
|
+
const opts = pendingInput.options ?? [];
|
|
511
|
+
for (const opt of opts) {
|
|
512
|
+
const id = `${pendingInput.id}-${opt.id}`;
|
|
513
|
+
const radio = el("input", null, { type: "radio", name: "value", value: opt.id, id });
|
|
514
|
+
const lbl = el("label", null, { for: id });
|
|
515
|
+
lbl.append(radio, ` ${opt.label}`);
|
|
516
|
+
if (opt.description) {
|
|
517
|
+
const desc = el("div", "option-desc", { text: opt.description });
|
|
518
|
+
lbl.append(desc);
|
|
519
|
+
}
|
|
520
|
+
inputEl.append(lbl);
|
|
521
|
+
}
|
|
522
|
+
form.append(inputEl);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case "confirm": {
|
|
526
|
+
const row = el("div", "banner-confirm-row");
|
|
527
|
+
const yesId = `${pendingInput.id}-yes`;
|
|
528
|
+
const noId = `${pendingInput.id}-no`;
|
|
529
|
+
const yes = el("label", null, { for: yesId });
|
|
530
|
+
yes.append(el("input", null, { type: "radio", name: "value", value: "yes", id: yesId }), " Yes");
|
|
531
|
+
const no = el("label", null, { for: noId });
|
|
532
|
+
no.append(el("input", null, { type: "radio", name: "value", value: "no", id: noId }), " No");
|
|
533
|
+
row.append(yes, no);
|
|
534
|
+
form.append(row);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "input":
|
|
538
|
+
case "ask":
|
|
539
|
+
default: {
|
|
540
|
+
const ta = el("textarea", "banner-textarea", {
|
|
541
|
+
rows: "3",
|
|
542
|
+
placeholder: pendingInput.placeholder || "Type your response…",
|
|
543
|
+
name: "value",
|
|
544
|
+
});
|
|
545
|
+
form.append(ta);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const actions = el("div", "banner-actions");
|
|
550
|
+
const cancel = el("button", "btn", { text: "Cancel", type: "button" });
|
|
551
|
+
cancel.addEventListener("click", () => {
|
|
552
|
+
if (confirm("Discard this question? The agent will continue without your answer.")) {
|
|
553
|
+
banner.hidden = true;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
const submit = el("submit", "btn btn-primary", { text: "Send", type: "submit" });
|
|
557
|
+
actions.append(cancel, submit);
|
|
558
|
+
form.append(actions);
|
|
559
|
+
|
|
560
|
+
form.addEventListener("submit", async (e) => {
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
const fd = new FormData(form);
|
|
563
|
+
let body;
|
|
564
|
+
if (pendingInput.type === "choice") {
|
|
565
|
+
body = { inputId: pendingInput.id, optionId: String(fd.get("value") || "") };
|
|
566
|
+
if (!body.optionId) return alert("Please choose an option.");
|
|
567
|
+
} else if (pendingInput.type === "confirm") {
|
|
568
|
+
body = { inputId: pendingInput.id, value: String(fd.get("value") || "") };
|
|
569
|
+
if (!body.value) return alert("Please choose Yes or No.");
|
|
570
|
+
} else {
|
|
571
|
+
body = { inputId: pendingInput.id, value: String(fd.get("value") || "").trim() };
|
|
572
|
+
if (!body.value) return alert("Please type a response.");
|
|
573
|
+
}
|
|
574
|
+
submit.disabled = true;
|
|
575
|
+
try {
|
|
576
|
+
await api("POST", `/api/tasks/${task.id}/respond`, body);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
alert(`Failed to send response: ${err.message}`);
|
|
579
|
+
submit.disabled = false;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
banner.append(form);
|
|
584
|
+
root.append(banner);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ─── Render comments panel ──────────────────────────────────────────────────
|
|
588
|
+
// Best-effort resolve of the current user; used to decide whether the
|
|
589
|
+
// delete affordance is shown (only your own comments can be deleted).
|
|
590
|
+
let cachedMe = null;
|
|
591
|
+
async function fetchMe() {
|
|
592
|
+
if (cachedMe) return cachedMe;
|
|
593
|
+
try {
|
|
594
|
+
cachedMe = await api("GET", "/api/me");
|
|
595
|
+
} catch {
|
|
596
|
+
cachedMe = { name: "user" };
|
|
597
|
+
}
|
|
598
|
+
try { window.OpenKanCurrentUser = cachedMe; } catch {}
|
|
599
|
+
return cachedMe;
|
|
600
|
+
}
|
|
601
|
+
function meName() {
|
|
602
|
+
return cachedMe?.name || window.OpenKanCurrentUser?.name || "user";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderCommentCard(task, c) {
|
|
606
|
+
const author = String(c.author || "user");
|
|
607
|
+
const seed = author;
|
|
608
|
+
const card = el("article", `comment${c.resolved ? " resolved" : ""}`, { "data-cid": c.id });
|
|
609
|
+
|
|
610
|
+
// Header row — avatar, author name, timestamp on the right.
|
|
611
|
+
const head = el("div", "comment-head");
|
|
612
|
+
const av = el("span", "avatar-circle avatar-md");
|
|
613
|
+
av.style.setProperty("--avatar-bg", avatarColorFor(seed));
|
|
614
|
+
av.textContent = initialsFor(author);
|
|
615
|
+
head.append(av);
|
|
616
|
+
|
|
617
|
+
const authorWrap = el("div", "comment-author");
|
|
618
|
+
authorWrap.append(el("span", "comment-author-name", {
|
|
619
|
+
text: author,
|
|
620
|
+
title: author,
|
|
621
|
+
}));
|
|
622
|
+
authorWrap.append(el("span", "comment-timestamp", {
|
|
623
|
+
text: relativeTime(c.createdAt),
|
|
624
|
+
title: c.createdAt,
|
|
625
|
+
}));
|
|
626
|
+
head.append(authorWrap);
|
|
627
|
+
card.append(head);
|
|
628
|
+
|
|
629
|
+
// Line indicator (small dim chip) — surfaces which block the comment was
|
|
630
|
+
// attached to so readers can correlate without scrolling.
|
|
631
|
+
if (c.line || c.blockId) {
|
|
632
|
+
const lineChip = el("span", "comment-line-chip", {
|
|
633
|
+
text: c.blockId ? `block ${String(c.blockId).slice(0, 8)} · line ${c.line || "?"}` : `line ${c.line || "?"}`,
|
|
634
|
+
title: `blockId: ${c.blockId || ""}`,
|
|
635
|
+
});
|
|
636
|
+
card.append(lineChip);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
card.append(el("div", "comment-text", { text: c.text }));
|
|
640
|
+
|
|
641
|
+
// Resolved footer (if resolved). The server populates `resolvedBy` and
|
|
642
|
+
// `resolvedAt`; fall back gracefully when older tasks don't carry them.
|
|
643
|
+
if (c.resolved) {
|
|
644
|
+
const by = c.resolvedBy || "user";
|
|
645
|
+
const at = c.resolvedAt || c.createdAt;
|
|
646
|
+
card.append(el("div", "comment-resolved-foot", {
|
|
647
|
+
text: `✓ resolved by ${by} · ${relativeTime(at)}`,
|
|
648
|
+
title: at,
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Actions row — Reply (always shown; focuses the composer for the same
|
|
653
|
+
// block when one is open, else scrolls the block into view) + Resolve +
|
|
654
|
+
// Delete (delete only for the author).
|
|
655
|
+
const actions = el("div", "comment-actions");
|
|
656
|
+
const reply = el("button", "comment-action-link", {
|
|
657
|
+
text: "Reply",
|
|
658
|
+
type: "button",
|
|
659
|
+
title: "Open the comment composer for this block",
|
|
660
|
+
});
|
|
661
|
+
reply.addEventListener("click", () => {
|
|
662
|
+
const root = document.querySelector(".task-view");
|
|
663
|
+
if (!root) return;
|
|
664
|
+
const block = root.querySelector(`.mdx-block[data-block-id="${CSS.escape(c.blockId || "")}"]`);
|
|
665
|
+
if (block) {
|
|
666
|
+
block.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
667
|
+
// Programmatically dispatch a click on the block so the existing
|
|
668
|
+
// mdx-viewer handler opens the composer.
|
|
669
|
+
try { block.click(); } catch {}
|
|
670
|
+
// Focus the textarea after a tick so the click handler has time to
|
|
671
|
+
// insert the composer.
|
|
672
|
+
setTimeout(() => {
|
|
673
|
+
const ta = document.querySelector(".mdx-block-comment-composer .composer-textarea");
|
|
674
|
+
if (ta) try { ta.focus(); } catch {}
|
|
675
|
+
}, 80);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
actions.append(reply);
|
|
679
|
+
|
|
680
|
+
const toggle = el("button", "btn btn-icon-sm", {
|
|
681
|
+
text: c.resolved ? "Unresolve" : "Resolve",
|
|
682
|
+
type: "button",
|
|
683
|
+
});
|
|
684
|
+
toggle.addEventListener("click", async () => {
|
|
685
|
+
toggle.disabled = true;
|
|
686
|
+
try {
|
|
687
|
+
await api("PATCH", `/api/tasks/${task.id}/comments/${c.id}`, {
|
|
688
|
+
resolved: !c.resolved,
|
|
689
|
+
author: meName(),
|
|
690
|
+
});
|
|
691
|
+
} catch (err) {
|
|
692
|
+
alert(`Failed: ${err.message}`);
|
|
693
|
+
toggle.disabled = false;
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
actions.append(toggle);
|
|
697
|
+
|
|
698
|
+
if (author === meName()) {
|
|
699
|
+
const del = el("button", "btn btn-icon-sm danger comment-delete", {
|
|
700
|
+
text: "×",
|
|
701
|
+
type: "button",
|
|
702
|
+
title: "Delete this comment",
|
|
703
|
+
"aria-label": "Delete this comment",
|
|
704
|
+
});
|
|
705
|
+
del.addEventListener("click", async () => {
|
|
706
|
+
if (!confirm("Delete this comment?")) return;
|
|
707
|
+
del.disabled = true;
|
|
708
|
+
try {
|
|
709
|
+
await api("DELETE", `/api/tasks/${task.id}/comments/${c.id}`);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
alert(`Failed: ${err.message}`);
|
|
712
|
+
del.disabled = false;
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
actions.append(del);
|
|
716
|
+
}
|
|
717
|
+
card.append(actions);
|
|
718
|
+
return card;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function renderComments(root, task, comments) {
|
|
722
|
+
root.innerHTML = "";
|
|
723
|
+
const panel = el("section", "comments-panel");
|
|
724
|
+
panel.append(el("h2", "panel-title", { text: `Comments (${comments.length})` }));
|
|
725
|
+
|
|
726
|
+
if (comments.length === 0) {
|
|
727
|
+
panel.append(el("div", "panel-empty", { text: "Click any block in the MDX to leave a comment." }));
|
|
728
|
+
} else {
|
|
729
|
+
const list = el("div", "comments-list");
|
|
730
|
+
for (const c of comments) list.append(renderCommentCard(task, c));
|
|
731
|
+
panel.append(list);
|
|
732
|
+
}
|
|
733
|
+
root.append(panel);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ─── Render inputs history ─────────────────────────────────────────────────
|
|
737
|
+
function renderInputs(root, task, inputs) {
|
|
738
|
+
root.innerHTML = "";
|
|
739
|
+
const panel = el("section", "inputs-panel");
|
|
740
|
+
panel.append(el("h2", "panel-title", { text: `Inputs (${inputs.length})` }));
|
|
741
|
+
|
|
742
|
+
if (inputs.length === 0) {
|
|
743
|
+
panel.append(el("div", "panel-empty", { text: "No inputs yet." }));
|
|
744
|
+
} else {
|
|
745
|
+
const list = el("div", "inputs-list");
|
|
746
|
+
for (const inp of inputs) {
|
|
747
|
+
const item = el("article", `input-history-item status-${inp.status}`, { "data-iid": inp.id });
|
|
748
|
+
item.append(el("div", "input-meta", {
|
|
749
|
+
text: `${inp.type} · ${inp.status} · ${inp.createdAt}`,
|
|
750
|
+
}));
|
|
751
|
+
item.append(el("div", "input-question", { text: inp.question || "" }));
|
|
752
|
+
if (inp.status === "responded") {
|
|
753
|
+
const ans = inp.responseOptionId
|
|
754
|
+
? `(option) ${inp.options?.find((o) => o.id === inp.responseOptionId)?.label ?? inp.responseOptionId}`
|
|
755
|
+
: inp.response ?? "";
|
|
756
|
+
item.append(el("div", "input-response", { text: `↳ ${ans}` }));
|
|
757
|
+
}
|
|
758
|
+
list.append(item);
|
|
759
|
+
}
|
|
760
|
+
panel.append(list);
|
|
761
|
+
}
|
|
762
|
+
root.append(panel);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── Render assignees strip + agent progress timeline + last activity ─────
|
|
766
|
+
// Returns a normalized list of {label, seed} for the assignees on a task.
|
|
767
|
+
// Supports both shapes: `assignees: string[]` (new index payload) and
|
|
768
|
+
// `contributors: Array<{name, email}>` (task detail payload).
|
|
769
|
+
function getAssignees(task) {
|
|
770
|
+
if (Array.isArray(task?.assignees) && task.assignees.length > 0) {
|
|
771
|
+
return task.assignees
|
|
772
|
+
.map((a) => {
|
|
773
|
+
if (a && typeof a === "object") {
|
|
774
|
+
const seed = String(a.email || a.name || "");
|
|
775
|
+
return { label: a.name || a.email || "?", seed };
|
|
776
|
+
}
|
|
777
|
+
const s = String(a || "").trim();
|
|
778
|
+
return s ? { label: s, seed: s } : null;
|
|
779
|
+
})
|
|
780
|
+
.filter(Boolean);
|
|
781
|
+
}
|
|
782
|
+
if (Array.isArray(task?.contributors)) {
|
|
783
|
+
return task.contributors
|
|
784
|
+
.map((c) => {
|
|
785
|
+
const seed = String(c?.email || c?.name || "");
|
|
786
|
+
if (!seed) return null;
|
|
787
|
+
return { label: c.name || c.email || "?", seed };
|
|
788
|
+
})
|
|
789
|
+
.filter(Boolean);
|
|
790
|
+
}
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// (assignees are rendered inline inside renderMetadata's dl/dt/dd grid.)
|
|
795
|
+
|
|
796
|
+
// ─── Footer actions ────────────────────────────────────────────────────────
|
|
797
|
+
function renderFooter(root, task) {
|
|
798
|
+
root.innerHTML = "";
|
|
799
|
+
const footer = el("footer", "task-footer");
|
|
800
|
+
const state = effectiveState(task);
|
|
801
|
+
|
|
802
|
+
// "Move to next column" — hidden when archived or on the last column.
|
|
803
|
+
if (!task.archived) {
|
|
804
|
+
const idx = COLUMN_ORDER.indexOf(task.column);
|
|
805
|
+
if (idx >= 0 && idx < COLUMN_ORDER.length - 1) {
|
|
806
|
+
const next = COLUMN_ORDER[idx + 1];
|
|
807
|
+
const move = el("button", "btn btn-primary", { text: `→ ${COLUMN_LABEL[next]}`, type: "button" });
|
|
808
|
+
move.title = `Move to ${COLUMN_LABEL[next]}`;
|
|
809
|
+
move.addEventListener("click", async () => {
|
|
810
|
+
move.disabled = true;
|
|
811
|
+
try {
|
|
812
|
+
await api("PATCH", `/api/tasks/${task.id}`, { column: next });
|
|
813
|
+
} catch (err) {
|
|
814
|
+
alert(`Failed: ${err.message}`);
|
|
815
|
+
move.disabled = false;
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
footer.append(move);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (state === "running") {
|
|
823
|
+
const abort = el("button", "btn btn-danger", { text: "Abort", type: "button" });
|
|
824
|
+
abort.addEventListener("click", async () => {
|
|
825
|
+
if (!confirm("Abort this task?")) return;
|
|
826
|
+
try { await api("POST", `/api/tasks/${task.id}/abort`); }
|
|
827
|
+
catch (err) { alert(`Failed: ${err.message}`); }
|
|
828
|
+
});
|
|
829
|
+
footer.append(abort);
|
|
830
|
+
} else if (state === "idle" || state === "done" || state === "failed" || state === "cancelled") {
|
|
831
|
+
const start = el("button", "btn", { text: "Run", type: "button" });
|
|
832
|
+
start.addEventListener("click", async () => {
|
|
833
|
+
start.disabled = true;
|
|
834
|
+
try { await api("POST", `/api/tasks/${task.id}/start`); }
|
|
835
|
+
catch (err) { alert(`Failed: ${err.message}`); start.disabled = false; }
|
|
836
|
+
});
|
|
837
|
+
footer.append(start);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Edit — focuses the inline-editable title (or falls back to the modal if
|
|
841
|
+
// the inline editor isn't mounted). The inline-edit path is the new
|
|
842
|
+
// primary; the modal stays as a fallback for accessibility.
|
|
843
|
+
const edit = el("button", "btn", { text: "✎ Edit", type: "button" });
|
|
844
|
+
edit.title = "Edit title and description inline";
|
|
845
|
+
edit.addEventListener("click", () => {
|
|
846
|
+
const titleEl = document.querySelector(".task-title");
|
|
847
|
+
if (titleEl && titleEl.isContentEditable) {
|
|
848
|
+
try { titleEl.focus(); } catch {}
|
|
849
|
+
// Select all text so the user can immediately type a replacement.
|
|
850
|
+
const range = document.createRange();
|
|
851
|
+
range.selectNodeContents(titleEl);
|
|
852
|
+
const sel = window.getSelection();
|
|
853
|
+
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (window.OpenKanEditTask?.open) window.OpenKanEditTask.open(task.id);
|
|
857
|
+
else alert("Edit modal not available — refresh the page.");
|
|
858
|
+
});
|
|
859
|
+
footer.append(edit);
|
|
860
|
+
|
|
861
|
+
// Template help — opens a modal showing the canonical task MDX template.
|
|
862
|
+
// Surfaces the same content the server uses when seeding new tasks, so
|
|
863
|
+
// authors can copy the structure directly into their task description.
|
|
864
|
+
const tmpl = el("button", "btn", { text: "Template", type: "button" });
|
|
865
|
+
tmpl.title = "Show the canonical task template";
|
|
866
|
+
tmpl.addEventListener("click", () => openTemplateModal(task));
|
|
867
|
+
footer.append(tmpl);
|
|
868
|
+
|
|
869
|
+
// Archive / Restore (placed before the artifact link so it's the natural
|
|
870
|
+
// left-to-right read order).
|
|
871
|
+
if (task.archived) {
|
|
872
|
+
const restore = el("button", "btn", { text: "↩ Restore", type: "button" });
|
|
873
|
+
restore.title = "Restore from archive";
|
|
874
|
+
restore.addEventListener("click", async () => {
|
|
875
|
+
restore.disabled = true;
|
|
876
|
+
try { await api("POST", `/api/tasks/${task.id}/restore`); }
|
|
877
|
+
catch (err) { alert(`Failed: ${err.message}`); restore.disabled = false; }
|
|
878
|
+
});
|
|
879
|
+
footer.append(restore);
|
|
880
|
+
} else {
|
|
881
|
+
const archive = el("button", "btn", { text: "Archive", type: "button" });
|
|
882
|
+
archive.title = "Hide this task from the active board";
|
|
883
|
+
archive.addEventListener("click", async () => {
|
|
884
|
+
if (!confirm(`Archive "${task.title}"? You can restore it later.`)) return;
|
|
885
|
+
archive.disabled = true;
|
|
886
|
+
try { await api("POST", `/api/tasks/${task.id}/archive`); }
|
|
887
|
+
catch (err) { alert(`Failed: ${err.message}`); archive.disabled = false; }
|
|
888
|
+
});
|
|
889
|
+
footer.append(archive);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (task.artifact) {
|
|
893
|
+
const a = el("a", "btn", {
|
|
894
|
+
href: `/artifacts/tasks/${task.id}`,
|
|
895
|
+
text: "View Artifact ↗",
|
|
896
|
+
target: "_blank",
|
|
897
|
+
rel: "noopener",
|
|
898
|
+
});
|
|
899
|
+
footer.append(a);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const del = el("button", "btn btn-danger", { text: "Delete", type: "button" });
|
|
903
|
+
del.addEventListener("click", async () => {
|
|
904
|
+
if (!confirm(`Delete task "${task.title}"? This cannot be undone.`)) return;
|
|
905
|
+
try {
|
|
906
|
+
await api("DELETE", `/api/tasks/${task.id}`);
|
|
907
|
+
window.OpenKanTaskView.close();
|
|
908
|
+
} catch (err) {
|
|
909
|
+
alert(`Failed: ${err.message}`);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
footer.append(del);
|
|
913
|
+
|
|
914
|
+
root.append(footer);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ─── Template modal ─────────────────────────────────────────────────────────
|
|
918
|
+
// Fetches /api/template (the canonical MDX seed for new tasks) and shows
|
|
919
|
+
// it in a copyable modal. Falls back to a sensible default template if the
|
|
920
|
+
// endpoint is not yet implemented.
|
|
921
|
+
let templateBodyCache = null;
|
|
922
|
+
let templateBackdrop = null;
|
|
923
|
+
|
|
924
|
+
function getTemplateModal() {
|
|
925
|
+
if (templateBackdrop) return templateBackdrop;
|
|
926
|
+
templateBackdrop = document.getElementById("template-backdrop");
|
|
927
|
+
if (!templateBackdrop) return null;
|
|
928
|
+
templateBackdrop.addEventListener("click", (e) => {
|
|
929
|
+
if (e.target === templateBackdrop) closeTemplateModal();
|
|
930
|
+
});
|
|
931
|
+
templateBackdrop.querySelectorAll("[data-close-template]").forEach((b) =>
|
|
932
|
+
b.addEventListener("click", closeTemplateModal),
|
|
933
|
+
);
|
|
934
|
+
document.addEventListener("keydown", (e) => {
|
|
935
|
+
if (e.key === "Escape" && !templateBackdrop.hidden) {
|
|
936
|
+
e.stopPropagation();
|
|
937
|
+
closeTemplateModal();
|
|
938
|
+
}
|
|
939
|
+
}, true);
|
|
940
|
+
return templateBackdrop;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function openTemplateModal(task) {
|
|
944
|
+
const backdrop = getTemplateModal();
|
|
945
|
+
if (!backdrop) return;
|
|
946
|
+
const body = document.getElementById("template-body");
|
|
947
|
+
const copyBtn = document.getElementById("template-copy-btn");
|
|
948
|
+
if (body) body.innerHTML = '<div class="panel-empty">Loading template…</div>';
|
|
949
|
+
backdrop.hidden = false;
|
|
950
|
+
if (copyBtn) copyBtn.disabled = true;
|
|
951
|
+
|
|
952
|
+
let templateText = templateBodyCache;
|
|
953
|
+
if (!templateText) {
|
|
954
|
+
try {
|
|
955
|
+
const payload = await api("GET", "/api/template");
|
|
956
|
+
templateText = typeof payload === "string"
|
|
957
|
+
? payload
|
|
958
|
+
: (payload?.template || payload?.mdx || payload?.body || payload?.content || "");
|
|
959
|
+
} catch (err) {
|
|
960
|
+
templateText = null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (!templateText) {
|
|
964
|
+
templateText = defaultTemplateText(task);
|
|
965
|
+
} else {
|
|
966
|
+
templateBodyCache = templateText;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (body) {
|
|
970
|
+
body.innerHTML = "";
|
|
971
|
+
const intro = el("p", "template-intro", {
|
|
972
|
+
text: "Use this template as the starting point for the task's MDX body. Each H2 is a section the UI understands (Description, Acceptance criteria, Agent progress, …).",
|
|
973
|
+
});
|
|
974
|
+
body.append(intro);
|
|
975
|
+
const pre = el("pre", "template-preview");
|
|
976
|
+
const code = el("code", "language-markdown", { text: templateText });
|
|
977
|
+
pre.append(code);
|
|
978
|
+
body.append(pre);
|
|
979
|
+
body.append(el("p", "template-hint", {
|
|
980
|
+
text: "Tip: paste this into the task's description field, then edit it as you go.",
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
if (copyBtn) {
|
|
984
|
+
copyBtn.disabled = false;
|
|
985
|
+
copyBtn.onclick = async () => {
|
|
986
|
+
try {
|
|
987
|
+
await navigator.clipboard.writeText(templateText);
|
|
988
|
+
copyBtn.textContent = "Copied ✓";
|
|
989
|
+
setTimeout(() => { copyBtn.textContent = "Copy to clipboard"; }, 1500);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
alert(`Copy failed: ${err.message || err}`);
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function closeTemplateModal() {
|
|
998
|
+
const backdrop = getTemplateModal();
|
|
999
|
+
if (backdrop) backdrop.hidden = true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Local fallback so the modal still works during the rollout of
|
|
1003
|
+
// /api/template. Mirrors the structure of kanban/template.mdx.
|
|
1004
|
+
function defaultTemplateText(task) {
|
|
1005
|
+
const title = (task && task.title) || "Untitled task";
|
|
1006
|
+
return [
|
|
1007
|
+
`# ${title}`,
|
|
1008
|
+
"",
|
|
1009
|
+
"## Description",
|
|
1010
|
+
"",
|
|
1011
|
+
"What this task is, why it matters, and what success looks like.",
|
|
1012
|
+
"",
|
|
1013
|
+
"## Acceptance criteria",
|
|
1014
|
+
"",
|
|
1015
|
+
"- [ ] …",
|
|
1016
|
+
"- [ ] …",
|
|
1017
|
+
"",
|
|
1018
|
+
"## Implementation notes",
|
|
1019
|
+
"",
|
|
1020
|
+
"Free-form notes for whoever picks this up.",
|
|
1021
|
+
"",
|
|
1022
|
+
"## Agent progress",
|
|
1023
|
+
"",
|
|
1024
|
+
"> [HH:MM:SS] tool: edit_file on path/to/file — short summary",
|
|
1025
|
+
"> [HH:MM:SS] tool: run_command — short summary",
|
|
1026
|
+
"",
|
|
1027
|
+
].join("\n");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ─── Render task header metadata strip ──────────────────────────────────────
|
|
1031
|
+
// Clean, scannable dl/dt/dd grid with tag chips and assignees below.
|
|
1032
|
+
function renderMetadata(root, task, lastActivity) {
|
|
1033
|
+
root.innerHTML = "";
|
|
1034
|
+
const meta = el("div", "task-header-meta");
|
|
1035
|
+
|
|
1036
|
+
// ── Definition list — label/value pairs in a clean grid ──────────────
|
|
1037
|
+
const dl = el("dl", "meta-dl");
|
|
1038
|
+
|
|
1039
|
+
// Column → human label
|
|
1040
|
+
if (task.column) {
|
|
1041
|
+
const lbl = el("dt", null, { text: "Column" });
|
|
1042
|
+
const val = el("dd", null, { text: COLUMN_LABEL[task.column] || task.column });
|
|
1043
|
+
dl.append(lbl, val);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Category
|
|
1047
|
+
const cat = String(task.category || "").toLowerCase();
|
|
1048
|
+
if (cat) {
|
|
1049
|
+
const lbl = el("dt", null, { text: "Category" });
|
|
1050
|
+
const catCls = CATEGORIES.has(cat) ? `tag-chip category c-${cat}` : "tag-chip category";
|
|
1051
|
+
const val = el("dd", null, {});
|
|
1052
|
+
val.append(el("span", catCls, { text: cat, title: `category: ${cat}` }));
|
|
1053
|
+
dl.append(lbl, val);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Priority
|
|
1057
|
+
const p = PRIORITY_META[task.priority];
|
|
1058
|
+
if (p && task.priority) {
|
|
1059
|
+
const lbl = el("dt", null, { text: "Priority" });
|
|
1060
|
+
const val = el("dd", null, {});
|
|
1061
|
+
val.append(el("span", `tag-chip priority priority-${task.priority}`, {
|
|
1062
|
+
text: `${p.code} ${p.label}`,
|
|
1063
|
+
title: `priority: ${p.label}`,
|
|
1064
|
+
}));
|
|
1065
|
+
dl.append(lbl, val);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Effort
|
|
1069
|
+
if (task.effort) {
|
|
1070
|
+
const effortText = String(task.effort).toUpperCase();
|
|
1071
|
+
const lbl = el("dt", null, { text: "Effort" });
|
|
1072
|
+
const val = el("dd", null, {});
|
|
1073
|
+
val.append(el("span", "tag-chip effort", { text: effortText, title: `effort: ${effortText}` }));
|
|
1074
|
+
dl.append(lbl, val);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Agent / model
|
|
1078
|
+
if (task.agent || task.model) {
|
|
1079
|
+
const lbl = el("dt", null, { text: "Runner" });
|
|
1080
|
+
const val = el("dd", "runner-value", {});
|
|
1081
|
+
if (task.agent) val.append(el("span", "runner-agent", { text: task.agent }));
|
|
1082
|
+
if (task.agent && task.model) val.append(el("span", "runner-sep", { text: " · " }));
|
|
1083
|
+
if (task.model) val.append(el("span", "runner-model", { text: task.model }));
|
|
1084
|
+
dl.append(lbl, val);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Source path (where it was imported from). Render as a clickable link
|
|
1088
|
+
// so the user can jump straight to the file. The path is repo-relative
|
|
1089
|
+
// (e.g. "docs/roadmap.mdx"), so we prefix with "/" for an absolute path
|
|
1090
|
+
// that the dev server will serve.
|
|
1091
|
+
if (task.source?.path) {
|
|
1092
|
+
const lbl = el("dt", null, { text: "Source" });
|
|
1093
|
+
const val = el("dd", "source-dd", {});
|
|
1094
|
+
const path = String(task.source.path);
|
|
1095
|
+
const line = task.source.line ?? "?";
|
|
1096
|
+
const link = el("a", "source-link", {
|
|
1097
|
+
href: `/${path}`,
|
|
1098
|
+
target: "_blank",
|
|
1099
|
+
rel: "noopener",
|
|
1100
|
+
title: `Open ${path}:${line} in a new tab`,
|
|
1101
|
+
});
|
|
1102
|
+
link.append(
|
|
1103
|
+
el("span", "source-link-icon", { text: "📄", "aria-hidden": "true" }),
|
|
1104
|
+
el("span", "source-link-text", { text: `${path}:${line}` }),
|
|
1105
|
+
);
|
|
1106
|
+
val.append(link);
|
|
1107
|
+
dl.append(lbl, val);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Stale — surfaces when the server detected the source file has changed
|
|
1111
|
+
// since import. The user can re-derive tags via the organize endpoint
|
|
1112
|
+
// (kind: "rederive"). Falls back to /api/tasks/:id/organize if Thor's
|
|
1113
|
+
// per-task endpoint ships first.
|
|
1114
|
+
if (task.stale === true) {
|
|
1115
|
+
const lbl = el("dt", null, { text: "Stale" });
|
|
1116
|
+
const val = el("dd", "meta-stale", {});
|
|
1117
|
+
val.append(el("span", "stale-warning", {
|
|
1118
|
+
text: "⚠️ Source has changed since this task was imported.",
|
|
1119
|
+
}));
|
|
1120
|
+
const rederive = el("button", "btn btn-sm stale-rederive", {
|
|
1121
|
+
type: "button",
|
|
1122
|
+
text: "Re-derive tags",
|
|
1123
|
+
title: "Re-run the tag/category/priority extractor for this task",
|
|
1124
|
+
});
|
|
1125
|
+
rederive.addEventListener("click", async () => {
|
|
1126
|
+
rederive.disabled = true;
|
|
1127
|
+
const restore = rederive.textContent;
|
|
1128
|
+
rederive.textContent = "Re-deriving…";
|
|
1129
|
+
try {
|
|
1130
|
+
// Prefer /api/organize (the canonical endpoint). Fall back to a
|
|
1131
|
+
// per-task /api/tasks/:id/organize if Thor ships that one first.
|
|
1132
|
+
try {
|
|
1133
|
+
await api("POST", "/api/organize", {
|
|
1134
|
+
operations: [{ kind: "rederive", taskId: task.id }],
|
|
1135
|
+
});
|
|
1136
|
+
} catch (_) {
|
|
1137
|
+
await api("POST", `/api/tasks/${task.id}/organize`, { kind: "rederive" });
|
|
1138
|
+
}
|
|
1139
|
+
window.OpenKanSettings?.showToast?.("Tags re-derived", "success");
|
|
1140
|
+
// Re-fetch the task so the cleared `stale` flag + fresh tags show up.
|
|
1141
|
+
try {
|
|
1142
|
+
const fresh = await api("GET", `/api/tasks/${task.id}`);
|
|
1143
|
+
const t = fresh?.task || fresh;
|
|
1144
|
+
if (t && t.id && window.OpenKanBoard?.getFilter) {
|
|
1145
|
+
// No-op: the next SSE `task.updated` event will trigger a
|
|
1146
|
+
// re-render. We don't force-refresh here so we don't race
|
|
1147
|
+
// with the broadcast.
|
|
1148
|
+
}
|
|
1149
|
+
} catch (_) { /* soft-fail; the broadcast will sync */ }
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
alert(`Re-derive failed: ${err.message}`);
|
|
1152
|
+
rederive.disabled = false;
|
|
1153
|
+
rederive.textContent = restore;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
val.append(rederive);
|
|
1157
|
+
dl.append(lbl, val);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Assignees
|
|
1161
|
+
const assigneesList = getAssignees(task);
|
|
1162
|
+
if (assigneesList.length > 0) {
|
|
1163
|
+
const lbl = el("dt", null, { text: "Assignees" });
|
|
1164
|
+
const val = el("dd", "assignees-dd", {});
|
|
1165
|
+
for (const a of assigneesList) {
|
|
1166
|
+
const pair = el("span", "assignee-pair", { title: a.seed });
|
|
1167
|
+
const av = el("span", "assignee-avatar md");
|
|
1168
|
+
av.style.background = avatarColorFor(a.seed);
|
|
1169
|
+
av.textContent = initialsFor(a.label);
|
|
1170
|
+
pair.append(av, el("span", "assignee-name", { text: a.label }));
|
|
1171
|
+
val.append(pair);
|
|
1172
|
+
}
|
|
1173
|
+
dl.append(lbl, val);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Created / updated timestamps
|
|
1177
|
+
if (task.createdAt) {
|
|
1178
|
+
const lbl = el("dt", null, { text: "Created" });
|
|
1179
|
+
const val = el("dd", null, { text: shortDate(task.createdAt), title: task.createdAt });
|
|
1180
|
+
dl.append(lbl, val);
|
|
1181
|
+
}
|
|
1182
|
+
if (task.updatedAt && task.updatedAt !== task.createdAt) {
|
|
1183
|
+
const lbl = el("dt", null, { text: "Updated" });
|
|
1184
|
+
const val = el("dd", null, { text: relativeTime(task.updatedAt), title: task.updatedAt });
|
|
1185
|
+
dl.append(lbl, val);
|
|
1186
|
+
}
|
|
1187
|
+
if (lastActivity) {
|
|
1188
|
+
const lbl = el("dt", null, { text: "Last activity" });
|
|
1189
|
+
const val = el("dd", null, { text: relativeTime(lastActivity), title: lastActivity });
|
|
1190
|
+
dl.append(lbl, val);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (dl.children.length > 0) meta.append(dl);
|
|
1194
|
+
|
|
1195
|
+
// ── Tag chips row — below the dl ────────────────────────────────────
|
|
1196
|
+
const tags = Array.isArray(task.tags) ? task.tags : [];
|
|
1197
|
+
if (tags.length > 0) {
|
|
1198
|
+
const tagRow = el("div", "meta-tag-row");
|
|
1199
|
+
const tagLabel = el("span", "meta-tag-label", { text: "Tags" });
|
|
1200
|
+
tagRow.append(tagLabel);
|
|
1201
|
+
for (const t of tags) tagRow.append(makeTagChip(t));
|
|
1202
|
+
meta.append(tagRow);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
root.append(meta);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ─── Render agent progress timeline (if the MDX contains one) ──────────────
|
|
1209
|
+
// The MDX viewer is responsible for restyling the `## Agent progress`
|
|
1210
|
+
// section; here we just check whether any `.agent-progress-timeline` was
|
|
1211
|
+
// injected into the rendered MDX slot.
|
|
1212
|
+
function maybeRenderAgentProgress(metaSlot, task) {
|
|
1213
|
+
// The mdx-viewer adds the timeline inside its own root. Nothing to do here
|
|
1214
|
+
// — this hook is left for future enhancements (e.g. a list-of-actions
|
|
1215
|
+
// banner above the MDX). The visual timeline is rendered by the
|
|
1216
|
+
// mdx-viewer based on `## Agent progress` heading + blockquote lines.
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ─── Images panel (gallery + drop zone + paste) ────────────────────────────
|
|
1221
|
+
// Lives in the left column of the task main grid, just under the MDX slot.
|
|
1222
|
+
// Refreshes the gallery after each successful upload.
|
|
1223
|
+
const imagesState = {
|
|
1224
|
+
panel: null,
|
|
1225
|
+
taskId: null,
|
|
1226
|
+
fileInput: null,
|
|
1227
|
+
dropDetacher: null,
|
|
1228
|
+
pasteDetacher: null,
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
async function uploadFiles(taskId, files) {
|
|
1232
|
+
if (!imagesState.panel) return;
|
|
1233
|
+
const status = imagesState.panel.querySelector(".image-status");
|
|
1234
|
+
const gallery = imagesState.panel.querySelector(".image-gallery");
|
|
1235
|
+
const ok = [];
|
|
1236
|
+
const failed = [];
|
|
1237
|
+
for (const file of files) {
|
|
1238
|
+
if (status) status.textContent = `Uploading ${file.name || "image"}…`;
|
|
1239
|
+
try {
|
|
1240
|
+
await window.OpenKanImages.upload(taskId, file);
|
|
1241
|
+
ok.push(file.name || "image");
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
failed.push({ name: file.name || "image", error: err.message || String(err) });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (status) {
|
|
1247
|
+
if (failed.length === 0) {
|
|
1248
|
+
status.classList.remove("error");
|
|
1249
|
+
status.textContent = `Uploaded ${ok.length} image${ok.length === 1 ? "" : "s"}.`;
|
|
1250
|
+
} else {
|
|
1251
|
+
status.classList.add("error");
|
|
1252
|
+
const sample = failed.slice(0, 2).map((f) => `${f.name}: ${f.error}`).join("; ");
|
|
1253
|
+
status.textContent = `Uploaded ${ok.length}, failed ${failed.length}. ${sample}`;
|
|
1254
|
+
}
|
|
1255
|
+
setTimeout(() => {
|
|
1256
|
+
if (status.textContent.startsWith("Uploaded ")) {
|
|
1257
|
+
status.classList.remove("error");
|
|
1258
|
+
status.textContent = "";
|
|
1259
|
+
}
|
|
1260
|
+
}, 4000);
|
|
1261
|
+
}
|
|
1262
|
+
if (ok.length > 0) await refreshImages(taskId);
|
|
1263
|
+
void gallery;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function refreshImages(taskId) {
|
|
1267
|
+
if (!imagesState.panel) return;
|
|
1268
|
+
const gallery = imagesState.panel.querySelector(".image-gallery");
|
|
1269
|
+
const counter = imagesState.panel.querySelector(".images-count");
|
|
1270
|
+
if (!gallery) return;
|
|
1271
|
+
let list = [];
|
|
1272
|
+
try {
|
|
1273
|
+
list = await window.OpenKanImages.list(taskId);
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
gallery.innerHTML = "";
|
|
1276
|
+
gallery.append(el("li", "panel-empty", {
|
|
1277
|
+
text: `Failed to load images: ${err.message || err}`,
|
|
1278
|
+
}));
|
|
1279
|
+
if (counter) counter.textContent = "";
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
gallery.innerHTML = "";
|
|
1283
|
+
if (counter) counter.textContent = `${list.length} image${list.length === 1 ? "" : "s"}`;
|
|
1284
|
+
if (list.length === 0) return;
|
|
1285
|
+
for (const img of list) {
|
|
1286
|
+
gallery.append(renderImageThumb(taskId, img));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function renderImageThumb(taskId, img) {
|
|
1291
|
+
const li = el("li", "image-thumb");
|
|
1292
|
+
const url = window.OpenKanImages.srcFor(taskId, img.name);
|
|
1293
|
+
const image = el("img", null, {
|
|
1294
|
+
src: url,
|
|
1295
|
+
alt: img.name,
|
|
1296
|
+
loading: "lazy",
|
|
1297
|
+
title: img.name,
|
|
1298
|
+
});
|
|
1299
|
+
image.addEventListener("click", () => {
|
|
1300
|
+
window.OpenKanImages.openLightbox(url, img.name);
|
|
1301
|
+
});
|
|
1302
|
+
li.append(image);
|
|
1303
|
+
|
|
1304
|
+
const meta = el("div", "image-meta");
|
|
1305
|
+
meta.append(el("span", null, { text: img.name }));
|
|
1306
|
+
const sizeKb = Math.max(1, Math.round((img.size || 0) / 1024));
|
|
1307
|
+
meta.append(el("span", null, { text: `${sizeKb} KB` }));
|
|
1308
|
+
li.append(meta);
|
|
1309
|
+
|
|
1310
|
+
const actions = el("div", "image-actions");
|
|
1311
|
+
const copyBtn = el("button", "image-action-btn", {
|
|
1312
|
+
type: "button",
|
|
1313
|
+
title: "Copy markdown link",
|
|
1314
|
+
"aria-label": "Copy markdown link",
|
|
1315
|
+
text: "⧉",
|
|
1316
|
+
});
|
|
1317
|
+
copyBtn.addEventListener("click", async (e) => {
|
|
1318
|
+
e.stopPropagation();
|
|
1319
|
+
try {
|
|
1320
|
+
await window.OpenKanImages.copyMarkdown(taskId, img.name);
|
|
1321
|
+
copyBtn.textContent = "✓";
|
|
1322
|
+
setTimeout(() => { copyBtn.textContent = "⧉"; }, 1200);
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
alert(`Copy failed: ${err.message || err}`);
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
actions.append(copyBtn);
|
|
1328
|
+
|
|
1329
|
+
const delBtn = el("button", "image-action-btn danger", {
|
|
1330
|
+
type: "button",
|
|
1331
|
+
title: "Delete image",
|
|
1332
|
+
"aria-label": "Delete image",
|
|
1333
|
+
text: "×",
|
|
1334
|
+
});
|
|
1335
|
+
delBtn.addEventListener("click", async (e) => {
|
|
1336
|
+
e.stopPropagation();
|
|
1337
|
+
if (!confirm(`Delete image "${img.name}"?`)) return;
|
|
1338
|
+
delBtn.disabled = true;
|
|
1339
|
+
try {
|
|
1340
|
+
await window.OpenKanImages.remove(taskId, img.name);
|
|
1341
|
+
await refreshImages(taskId);
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
alert(`Delete failed: ${err.message || err}`);
|
|
1344
|
+
delBtn.disabled = false;
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
actions.append(delBtn);
|
|
1348
|
+
li.append(actions);
|
|
1349
|
+
return li;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function buildImagesPanel(taskId) {
|
|
1353
|
+
// Tear down any prior wiring before rebuilding.
|
|
1354
|
+
if (imagesState.dropDetacher) { imagesState.dropDetacher(); imagesState.dropDetacher = null; }
|
|
1355
|
+
|
|
1356
|
+
const panel = el("section", "images-panel");
|
|
1357
|
+
const head = el("div", "images-panel-head");
|
|
1358
|
+
head.append(
|
|
1359
|
+
el("h2", "panel-title", { text: "Images" }),
|
|
1360
|
+
el("span", "images-count", { text: "" }),
|
|
1361
|
+
);
|
|
1362
|
+
panel.append(head);
|
|
1363
|
+
|
|
1364
|
+
const dropZone = el("div", "image-drop-zone", {
|
|
1365
|
+
role: "button",
|
|
1366
|
+
tabindex: "0",
|
|
1367
|
+
"aria-label": "Upload images. Click, drag, or paste.",
|
|
1368
|
+
});
|
|
1369
|
+
dropZone.append(el("div", "image-drop-title", { text: "Drag images here" }));
|
|
1370
|
+
dropZone.append(el("div", "image-drop-sub", {
|
|
1371
|
+
text: "…or click to browse, or paste from clipboard (⌘V / Ctrl-V)",
|
|
1372
|
+
}));
|
|
1373
|
+
const fileInput = el("input", null, {
|
|
1374
|
+
type: "file",
|
|
1375
|
+
accept: "image/*",
|
|
1376
|
+
multiple: "multiple",
|
|
1377
|
+
"aria-hidden": "true",
|
|
1378
|
+
});
|
|
1379
|
+
dropZone.append(fileInput);
|
|
1380
|
+
|
|
1381
|
+
const trigger = (e) => {
|
|
1382
|
+
if (e.target === fileInput) return;
|
|
1383
|
+
e.preventDefault();
|
|
1384
|
+
fileInput.click();
|
|
1385
|
+
};
|
|
1386
|
+
dropZone.addEventListener("click", trigger);
|
|
1387
|
+
dropZone.addEventListener("keydown", (e) => {
|
|
1388
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1389
|
+
e.preventDefault();
|
|
1390
|
+
fileInput.click();
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
fileInput.addEventListener("change", async () => {
|
|
1394
|
+
const files = [...fileInput.files];
|
|
1395
|
+
if (files.length > 0) await uploadFiles(taskId, files);
|
|
1396
|
+
fileInput.value = "";
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
imagesState.dropDetacher = window.OpenKanImages.attachDropZone(dropZone, async (files) => {
|
|
1400
|
+
await uploadFiles(taskId, files);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
const gallery = el("ul", "image-gallery", { "aria-label": "Task images" });
|
|
1404
|
+
const status = el("div", "image-status");
|
|
1405
|
+
panel.append(dropZone, gallery, status);
|
|
1406
|
+
|
|
1407
|
+
imagesState.panel = panel;
|
|
1408
|
+
imagesState.taskId = taskId;
|
|
1409
|
+
imagesState.fileInput = fileInput;
|
|
1410
|
+
return panel;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// ─── Subtasks section ──────────────────────────────────────────────────────
|
|
1414
|
+
// Compact list of children. The parent id is passed in; we hit
|
|
1415
|
+
// /api/tasks/:id/subtasks (which Thor is shipping alongside the cascade).
|
|
1416
|
+
// Until that endpoint lands, we fall back to `task.subtasks` (the inline
|
|
1417
|
+
// array some payloads include).
|
|
1418
|
+
async function loadSubtasks(taskId) {
|
|
1419
|
+
try {
|
|
1420
|
+
const data = await api("GET", `/api/tasks/${taskId}/subtasks`);
|
|
1421
|
+
return Array.isArray(data?.subtasks) ? data.subtasks : Array.isArray(data) ? data : [];
|
|
1422
|
+
} catch (_) {
|
|
1423
|
+
// Endpoint may not exist yet — caller falls back to inline data.
|
|
1424
|
+
throw _;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function renderSubtasksPanel(parentTask, children, parentIdForNew) {
|
|
1429
|
+
const panel = el("section", "subtasks-panel");
|
|
1430
|
+
const head = el("div", "subtasks-head");
|
|
1431
|
+
head.append(el("h2", "panel-title", { text: `Subtasks (${children.length})` }));
|
|
1432
|
+
const addBtn = el("button", "btn btn-icon-sm", {
|
|
1433
|
+
type: "button",
|
|
1434
|
+
text: "+ Add subtask",
|
|
1435
|
+
title: "Create a new task as a subtask",
|
|
1436
|
+
});
|
|
1437
|
+
addBtn.addEventListener("click", () => {
|
|
1438
|
+
const btn = document.getElementById("new-task-btn");
|
|
1439
|
+
if (btn) btn.click();
|
|
1440
|
+
// After modal opens, set the hidden parentId and force the column to
|
|
1441
|
+
// the parent's column so subtasks usually land in the same place.
|
|
1442
|
+
const colSel = document.querySelector('select[name="column"]');
|
|
1443
|
+
if (colSel) {
|
|
1444
|
+
colSel.value = parentTask.column || colSel.value;
|
|
1445
|
+
colSel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1446
|
+
}
|
|
1447
|
+
const parentField = document.querySelector('input[name="parentId"]');
|
|
1448
|
+
if (parentField) {
|
|
1449
|
+
parentField.value = parentIdForNew || "";
|
|
1450
|
+
parentField.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1451
|
+
}
|
|
1452
|
+
const titleInput = document.querySelector('input[name="title"]');
|
|
1453
|
+
if (titleInput) setTimeout(() => titleInput.focus(), 0);
|
|
1454
|
+
});
|
|
1455
|
+
head.append(addBtn);
|
|
1456
|
+
panel.append(head);
|
|
1457
|
+
|
|
1458
|
+
if (children.length === 0) {
|
|
1459
|
+
const empty = el("div", "subtasks-empty");
|
|
1460
|
+
empty.append(
|
|
1461
|
+
el("div", "panel-empty", { text: "No subtasks yet. Click + Add subtask to create one." }),
|
|
1462
|
+
);
|
|
1463
|
+
panel.append(empty);
|
|
1464
|
+
} else {
|
|
1465
|
+
// Group pending vs done. Pending first (visual priority), then
|
|
1466
|
+
// completed collapsed under a small section header. The "done" set
|
|
1467
|
+
// includes any non-idle terminal state (done, failed, cancelled) so
|
|
1468
|
+
// the user sees the full picture without burying the work that still
|
|
1469
|
+
// needs attention.
|
|
1470
|
+
const done = children.filter((c) => {
|
|
1471
|
+
const s = effectiveState(c);
|
|
1472
|
+
return s === "done" || s === "failed" || s === "cancelled";
|
|
1473
|
+
});
|
|
1474
|
+
const pending = children.filter((c) => !done.includes(c));
|
|
1475
|
+
|
|
1476
|
+
const buildCard = (c) => {
|
|
1477
|
+
const child = el("button", "subtask-card", {
|
|
1478
|
+
type: "button",
|
|
1479
|
+
"data-id": c.id,
|
|
1480
|
+
title: c.id,
|
|
1481
|
+
});
|
|
1482
|
+
const state = effectiveState(c);
|
|
1483
|
+
child.append(el("span", `status-dot ${state}`, { title: state || "?" }));
|
|
1484
|
+
const body = el("div", "subtask-card-body");
|
|
1485
|
+
body.append(el("span", "subtask-card-title", { text: c.title || "(untitled)" }));
|
|
1486
|
+
if (c.description) {
|
|
1487
|
+
// Truncate description for the compact card view.
|
|
1488
|
+
const desc = String(c.description).replace(/\s+/g, " ").trim();
|
|
1489
|
+
const max = 140;
|
|
1490
|
+
body.append(el("span", "subtask-card-desc", {
|
|
1491
|
+
text: desc.length > max ? `${desc.slice(0, max)}…` : desc,
|
|
1492
|
+
}));
|
|
1493
|
+
}
|
|
1494
|
+
child.append(body);
|
|
1495
|
+
// Assignees — show the first one (or +N overflow) as a small avatar.
|
|
1496
|
+
const subAssignees = getAssignees(c);
|
|
1497
|
+
if (subAssignees.length > 0) {
|
|
1498
|
+
const wrap = el("span", "subtask-card-assignee");
|
|
1499
|
+
const first = subAssignees[0];
|
|
1500
|
+
const av = el("span", "assignee-avatar sm");
|
|
1501
|
+
av.style.background = avatarColorFor(first.seed);
|
|
1502
|
+
av.textContent = initialsFor(first.label);
|
|
1503
|
+
wrap.append(av);
|
|
1504
|
+
if (subAssignees.length > 1) {
|
|
1505
|
+
wrap.append(el("span", "assignee-overflow", { text: `+${subAssignees.length - 1}` }));
|
|
1506
|
+
}
|
|
1507
|
+
child.append(wrap);
|
|
1508
|
+
}
|
|
1509
|
+
child.addEventListener("click", () => {
|
|
1510
|
+
if (window.OpenKanTaskView?.open) window.OpenKanTaskView.open(c.id);
|
|
1511
|
+
});
|
|
1512
|
+
return child;
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
const list = el("div", "subtasks-list");
|
|
1516
|
+
for (const c of pending) list.append(buildCard(c));
|
|
1517
|
+
if (done.length > 0) {
|
|
1518
|
+
const headerRow = el("div", "subtasks-done-header");
|
|
1519
|
+
headerRow.append(el("span", "subtasks-done-label", {
|
|
1520
|
+
text: `Completed (${done.length})`,
|
|
1521
|
+
}));
|
|
1522
|
+
list.append(headerRow);
|
|
1523
|
+
for (const c of done) list.append(buildCard(c));
|
|
1524
|
+
}
|
|
1525
|
+
panel.append(list);
|
|
1526
|
+
}
|
|
1527
|
+
return panel;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// ─── Main render (one full pass) ───────────────────────────────────────────
|
|
1531
|
+
async function render(taskId) {
|
|
1532
|
+
const view = document.getElementById("task-view");
|
|
1533
|
+
if (!view) return;
|
|
1534
|
+
let payload;
|
|
1535
|
+
try {
|
|
1536
|
+
payload = await api("GET", `/api/tasks/${taskId}`);
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
view.innerHTML = "";
|
|
1539
|
+
view.append(el("div", "task-error", { text: `Failed to load task: ${err.message}` }));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Resolve the current user so the comments panel can decide which cards
|
|
1544
|
+
// show a delete affordance. Runs in parallel with the rest of the render
|
|
1545
|
+
// — the comments panel re-renders on its own when this resolves, via
|
|
1546
|
+
// the cachedMe-on-window trick used in renderCommentCard.
|
|
1547
|
+
fetchMe().then(() => {
|
|
1548
|
+
const slot = view.querySelector(".comments-slot");
|
|
1549
|
+
if (!slot) return;
|
|
1550
|
+
// Only re-render the comments — everything else stays put.
|
|
1551
|
+
slot.innerHTML = "";
|
|
1552
|
+
renderComments(slot, payload.task, payload.comments || []);
|
|
1553
|
+
}).catch(() => { /* /api/me unavailable — show no delete affordance */ });
|
|
1554
|
+
|
|
1555
|
+
const {
|
|
1556
|
+
task,
|
|
1557
|
+
comments = [],
|
|
1558
|
+
inputs = [],
|
|
1559
|
+
renderedHtml,
|
|
1560
|
+
renderedBlocks,
|
|
1561
|
+
} = payload;
|
|
1562
|
+
const pendingInput = (inputs || []).find((i) => i.status === "pending") || null;
|
|
1563
|
+
|
|
1564
|
+
// Last activity — fetch from /api/changelog in the background (best-effort).
|
|
1565
|
+
let lastActivity = null;
|
|
1566
|
+
api("GET", `/api/changelog?taskId=${encodeURIComponent(taskId)}&limit=1`)
|
|
1567
|
+
.then((data) => {
|
|
1568
|
+
const ev = Array.isArray(data?.events) ? data.events[0] : Array.isArray(data) ? data[0] : null;
|
|
1569
|
+
if (ev?.ts) {
|
|
1570
|
+
lastActivity = ev.ts;
|
|
1571
|
+
// Re-render just the metadata strip with the new last-activity.
|
|
1572
|
+
const slot = view.querySelector(".task-meta-slot");
|
|
1573
|
+
if (slot) renderMetadata(slot, task, lastActivity);
|
|
1574
|
+
}
|
|
1575
|
+
})
|
|
1576
|
+
.catch(() => { /* soft fail */ });
|
|
1577
|
+
|
|
1578
|
+
const headerEl = view.querySelector(".task-header-slot") || el("div", "task-header-slot");
|
|
1579
|
+
if (!headerEl.isConnected) view.append(headerEl);
|
|
1580
|
+
renderHeader(headerEl, task);
|
|
1581
|
+
|
|
1582
|
+
const metaEl = view.querySelector(".task-meta-slot") || el("div", "task-meta-slot");
|
|
1583
|
+
if (!metaEl.isConnected) view.append(metaEl);
|
|
1584
|
+
renderMetadata(metaEl, task, lastActivity);
|
|
1585
|
+
|
|
1586
|
+
const bannerEl = view.querySelector(".task-banner-slot") || el("div", "task-banner-slot");
|
|
1587
|
+
if (!bannerEl.isConnected) view.append(bannerEl);
|
|
1588
|
+
renderBanner(bannerEl, task, pendingInput);
|
|
1589
|
+
|
|
1590
|
+
// Main grid: left column (mdx + images) and right sidebar (comments+inputs).
|
|
1591
|
+
const main = view.querySelector(".task-main") || el("div", "task-main");
|
|
1592
|
+
if (!main.isConnected) view.append(main);
|
|
1593
|
+
main.innerHTML = "";
|
|
1594
|
+
|
|
1595
|
+
const leftCol = el("div", "task-left-col");
|
|
1596
|
+
const mdxSlot = el("div", "mdx-slot");
|
|
1597
|
+
// Right-click context menu on the MDX area. Wired here (not inside the
|
|
1598
|
+
// mdx-viewer) so the menu item definitions live with the task view.
|
|
1599
|
+
// Capture phase so we beat any potential stopPropagation from inner MDX
|
|
1600
|
+
// elements.
|
|
1601
|
+
mdxSlot.addEventListener("contextmenu", onMdxContextMenu, true);
|
|
1602
|
+
leftCol.append(mdxSlot);
|
|
1603
|
+
main.append(leftCol);
|
|
1604
|
+
|
|
1605
|
+
const sideSlot = el("div", "task-side");
|
|
1606
|
+
main.append(sideSlot);
|
|
1607
|
+
|
|
1608
|
+
const commentsEl = el("div", "comments-slot");
|
|
1609
|
+
const inputsEl = el("div", "inputs-slot");
|
|
1610
|
+
sideSlot.append(commentsEl, inputsEl);
|
|
1611
|
+
|
|
1612
|
+
window.OpenKanMdxViewer.mount(mdxSlot, {
|
|
1613
|
+
taskId,
|
|
1614
|
+
html: typeof renderedHtml === "string" ? renderedHtml : null,
|
|
1615
|
+
blocks: Array.isArray(renderedBlocks) ? renderedBlocks : null,
|
|
1616
|
+
comments: Array.isArray(comments) ? comments : null,
|
|
1617
|
+
onCommentAdded: async () => {
|
|
1618
|
+
try {
|
|
1619
|
+
const fresh = await api("GET", `/api/tasks/${taskId}/comments`);
|
|
1620
|
+
renderComments(commentsEl, task, fresh);
|
|
1621
|
+
} catch {}
|
|
1622
|
+
},
|
|
1623
|
+
onInputResponded: async (inputId, payload) => {
|
|
1624
|
+
try {
|
|
1625
|
+
await api("POST", `/api/tasks/${taskId}/respond`, payload);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
alert(`Failed to respond: ${err.message}`);
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
renderComments(commentsEl, task, comments || []);
|
|
1633
|
+
renderInputs(inputsEl, task, inputs || []);
|
|
1634
|
+
|
|
1635
|
+
// Images panel sits in the left column under the MDX viewer. Built last
|
|
1636
|
+
// because it kicks off its own GET to /api/tasks/:id/images.
|
|
1637
|
+
if (window.OpenKanImages) {
|
|
1638
|
+
const imagesPanel = buildImagesPanel(taskId);
|
|
1639
|
+
leftCol.append(imagesPanel);
|
|
1640
|
+
refreshImages(taskId).catch(() => { /* status line shows the error */ });
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Subtasks panel — under the MDX + images, still in the left column.
|
|
1644
|
+
// Best-effort fetch; if the endpoint is missing we fall back to whatever
|
|
1645
|
+
// the task payload already includes.
|
|
1646
|
+
let inlineSubtasks = Array.isArray(task?.subtasks) ? task.subtasks : [];
|
|
1647
|
+
let fetched = false;
|
|
1648
|
+
try {
|
|
1649
|
+
const remote = await loadSubtasks(taskId);
|
|
1650
|
+
if (Array.isArray(remote)) { inlineSubtasks = remote; fetched = true; }
|
|
1651
|
+
} catch (_) {
|
|
1652
|
+
// Endpoint not implemented yet — keep using the payload's inline field.
|
|
1653
|
+
}
|
|
1654
|
+
if (!fetched && inlineSubtasks.length === 0) {
|
|
1655
|
+
// Render an empty panel so the user always sees the + Add subtask affordance.
|
|
1656
|
+
}
|
|
1657
|
+
const subtasksPanel = renderSubtasksPanel(task, inlineSubtasks, taskId);
|
|
1658
|
+
leftCol.append(subtasksPanel);
|
|
1659
|
+
|
|
1660
|
+
const footerEl = view.querySelector(".task-footer-slot") || el("div", "task-footer-slot");
|
|
1661
|
+
if (!footerEl.isConnected) view.append(footerEl);
|
|
1662
|
+
renderFooter(footerEl, task);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
1666
|
+
async function open(taskId) {
|
|
1667
|
+
if (!taskId) return;
|
|
1668
|
+
currentTaskId = taskId;
|
|
1669
|
+
const view = document.getElementById("task-view");
|
|
1670
|
+
if (!view) return;
|
|
1671
|
+
// Hide the dashboard and all tab panes so the task view takes the full
|
|
1672
|
+
// body height. Without this, both #task-view and main.dashboard have
|
|
1673
|
+
// flex:1 and split the body 50/50, leaving an empty region above the
|
|
1674
|
+
// task view.
|
|
1675
|
+
const dash = document.querySelector("main.dashboard");
|
|
1676
|
+
if (dash) dash.style.display = "none";
|
|
1677
|
+
for (const pane of document.querySelectorAll(".tab-pane")) pane.hidden = true;
|
|
1678
|
+
view.hidden = false;
|
|
1679
|
+
// Re-render on relevant SSE events for THIS task.
|
|
1680
|
+
unsubs.push(on("task.updated", (p) => {
|
|
1681
|
+
const t = p?.task ?? p;
|
|
1682
|
+
if (t?.id === taskId) render(taskId);
|
|
1683
|
+
}));
|
|
1684
|
+
unsubs.push(on("task.input.asked", (p) => {
|
|
1685
|
+
if (p?.taskId === taskId) render(taskId);
|
|
1686
|
+
}));
|
|
1687
|
+
unsubs.push(on("task.input.responded", (p) => {
|
|
1688
|
+
if (p?.taskId === taskId) render(taskId);
|
|
1689
|
+
}));
|
|
1690
|
+
unsubs.push(on("task.comment.added", (p) => {
|
|
1691
|
+
if (p?.taskId === taskId) render(taskId);
|
|
1692
|
+
}));
|
|
1693
|
+
unsubs.push(on("task.comment.resolved", (p) => {
|
|
1694
|
+
if (p?.taskId === taskId) render(taskId);
|
|
1695
|
+
}));
|
|
1696
|
+
unsubs.push(on("task.comment.deleted", (p) => {
|
|
1697
|
+
if (p?.taskId === taskId) render(taskId);
|
|
1698
|
+
}));
|
|
1699
|
+
|
|
1700
|
+
// Paste-to-upload: active only while the task view is open. Use capture
|
|
1701
|
+
// phase so we beat any focused editor (the paste handler is registered
|
|
1702
|
+
// as a capture-phase listener in images.js, but we keep a safety net
|
|
1703
|
+
// for paste events that reach the document bubble phase).
|
|
1704
|
+
if (window.OpenKanImages) {
|
|
1705
|
+
imagesState.pasteDetacher = window.OpenKanImages.attachPasteHandler(async (files) => {
|
|
1706
|
+
await uploadFiles(taskId, files);
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
await render(taskId);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function close() {
|
|
1714
|
+
currentTaskId = null;
|
|
1715
|
+
for (const u of unsubs) { try { u(); } catch {} }
|
|
1716
|
+
unsubs = [];
|
|
1717
|
+
if (imagesState.pasteDetacher) { imagesState.pasteDetacher(); imagesState.pasteDetacher = null; }
|
|
1718
|
+
if (imagesState.dropDetacher) { imagesState.dropDetacher(); imagesState.dropDetacher = null; }
|
|
1719
|
+
imagesState.panel = null;
|
|
1720
|
+
imagesState.fileInput = null;
|
|
1721
|
+
imagesState.taskId = null;
|
|
1722
|
+
const view = document.getElementById("task-view");
|
|
1723
|
+
if (view) {
|
|
1724
|
+
view.hidden = true;
|
|
1725
|
+
view.innerHTML = "";
|
|
1726
|
+
}
|
|
1727
|
+
// Restore dashboard visibility (hidden when task view opened).
|
|
1728
|
+
const dash = document.querySelector("main.dashboard");
|
|
1729
|
+
if (dash) dash.style.display = "";
|
|
1730
|
+
// Restore the active tab via the canonical router — this updates both
|
|
1731
|
+
// pane visibility and tab-button active state, and rewrites the hash.
|
|
1732
|
+
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
|
1733
|
+
const tab = params.get("tab") || "tasks";
|
|
1734
|
+
if (window.OpenKanTabs) {
|
|
1735
|
+
window.OpenKanTabs.activate(tab);
|
|
1736
|
+
} else {
|
|
1737
|
+
const valid = ["tasks", "changelog", "contributors", "docs"];
|
|
1738
|
+
const name = valid.includes(tab) ? tab : "tasks";
|
|
1739
|
+
for (const pane of document.querySelectorAll(".tab-pane")) {
|
|
1740
|
+
pane.hidden = pane.dataset.tab !== name;
|
|
1741
|
+
}
|
|
1742
|
+
for (const btn of document.querySelectorAll(".tab")) {
|
|
1743
|
+
btn.classList.toggle("active", btn.dataset.tab === name);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
window.OpenKanTaskView = {
|
|
1749
|
+
open,
|
|
1750
|
+
close,
|
|
1751
|
+
/** The id of the currently displayed task, or null if the view is closed. */
|
|
1752
|
+
getCurrentTaskId() { return currentTaskId; },
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
// Kick off the current-user fetch on load so the comments panel can
|
|
1756
|
+
// immediately decide whether to show the delete affordance the first time
|
|
1757
|
+
// the task view opens. Safe to call multiple times — fetchMe caches.
|
|
1758
|
+
fetchMe().catch(() => { /* /api/me unavailable — comments render without delete */ });
|
|
1759
|
+
})();
|