@mgeri1993/claude-task-manager 1.0.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/.env.example +2 -0
- package/Dockerfile +11 -0
- package/LICENSE +21 -0
- package/README.hu.md +255 -0
- package/README.md +253 -0
- package/api/index.php +173 -0
- package/bin/add-agent.sh +77 -0
- package/bin/ctm +160 -0
- package/docker-compose.yml +14 -0
- package/engine/check-update.sh +48 -0
- package/engine/projects.sh +164 -0
- package/engine/task.sh +926 -0
- package/favicon.ico +6 -0
- package/favicon.svg +6 -0
- package/index.html +141 -0
- package/install.sh +231 -0
- package/js/ApiClient.js +63 -0
- package/js/App.js +547 -0
- package/js/BoardView.js +205 -0
- package/js/ContextPanel.js +47 -0
- package/js/ContextStore.js +39 -0
- package/js/ProjectStore.js +27 -0
- package/js/TaskModal.js +112 -0
- package/js/TaskStore.js +73 -0
- package/js/UrlState.js +53 -0
- package/js/Utils.js +202 -0
- package/js/i18n.js +338 -0
- package/js/main.js +3 -0
- package/package.json +85 -0
- package/style.css +396 -0
- package/templates/SKILL.md.tmpl +175 -0
- package/templates/agents/ctm-backend-developer.md.tmpl +53 -0
- package/templates/agents/ctm-code-investigator.md.tmpl +49 -0
- package/templates/agents/ctm-frontend-developer.md.tmpl +53 -0
- package/templates/hooks/allow-task-sh.sh.tmpl +59 -0
- package/templates/hooks/notify-inbox.sh.tmpl +53 -0
- package/templates/tm-custom.md.tmpl +54 -0
package/js/App.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { UrlState } from "./UrlState.js";
|
|
2
|
+
import { TaskStore } from "./TaskStore.js";
|
|
3
|
+
import { ContextStore } from "./ContextStore.js";
|
|
4
|
+
import { ProjectStore } from "./ProjectStore.js";
|
|
5
|
+
import { BoardView } from "./BoardView.js";
|
|
6
|
+
import { TaskModal } from "./TaskModal.js";
|
|
7
|
+
import { ContextPanel } from "./ContextPanel.js";
|
|
8
|
+
import { ApiClient } from "./ApiClient.js";
|
|
9
|
+
import { Utils } from "./Utils.js";
|
|
10
|
+
import { ICONS } from "./Utils.js";
|
|
11
|
+
import { I18n } from "./i18n.js";
|
|
12
|
+
|
|
13
|
+
const el = id => document.getElementById(id);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Main application controller: state, events, the polling loop, and wiring together the
|
|
17
|
+
* view/store classes. Multi-project: the Source selector switches between the projects
|
|
18
|
+
* registered in data/projects.json (see applyProject). Bilingual: English by default,
|
|
19
|
+
* switchable to Hungarian at runtime (see toggleLang / applyStaticI18n).
|
|
20
|
+
*/
|
|
21
|
+
export class App {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.dom = {
|
|
24
|
+
board: el("board"), stats: el("stats"), agents: el("agents"),
|
|
25
|
+
src: el("src"), q: el("q"), interval: el("interval"),
|
|
26
|
+
toggle: el("toggle"), refresh: el("refresh"), sort: el("sort"),
|
|
27
|
+
viewBoard: el("viewBoard"), viewSwim: el("viewSwim"), viewFeed: el("viewFeed"), compact: el("compact"),
|
|
28
|
+
qfReview: el("qfReview"), qfActive: el("qfActive"), qfBlocked: el("qfBlocked"), notifyBtn: el("notifyBtn"),
|
|
29
|
+
moduleFilter: el("moduleFilter"),
|
|
30
|
+
dot: el("dot"), statusText: el("statusText"), clock: el("clock"), banner: el("banner"),
|
|
31
|
+
overlay: el("overlay"), mTitle: el("mTitle"), mBody: el("mBody"), mClose: el("mClose"),
|
|
32
|
+
ctxBtn: el("ctxBtn"), ctxOverlay: el("ctxOverlay"), ctxClose: el("ctxClose"), ctxBody: el("ctxBody"), ctxUpdated: el("ctxUpdated"),
|
|
33
|
+
actor: el("actor"), actorList: el("actorList"),
|
|
34
|
+
srcProject: el("srcProject"),
|
|
35
|
+
agentsWrap: el("agentsWrap"), agentsHead: el("agentsHead"), agentsCount: el("agentsCount"),
|
|
36
|
+
projectsBtn: el("projectsBtn"), projectsOverlay: el("projectsOverlay"), projectsClose: el("projectsClose"), projectsBody: el("projectsBody"),
|
|
37
|
+
langBtn: el("langBtn"),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---- State ----
|
|
41
|
+
this.timer = null;
|
|
42
|
+
this.running = true;
|
|
43
|
+
this.sort = "activity";
|
|
44
|
+
this.view = "board";
|
|
45
|
+
this.compact = false;
|
|
46
|
+
this.agentFilter = null; // null = all; Set = agents to show
|
|
47
|
+
this.moduleFilter = null; // null = all modules; string = one module
|
|
48
|
+
this.quickFilter = null; // null | "review" | "active" | "blocked" (#5)
|
|
49
|
+
this.collapsedCols = new Set();
|
|
50
|
+
this.openTaskId = null;
|
|
51
|
+
this.pendingTask = null;
|
|
52
|
+
this.notify = false; // #3 browser notification on status change
|
|
53
|
+
this.relTimer = null; // #6 live relative-time ticker
|
|
54
|
+
this.clockTimer = null; // header's live clock (the mockup's "clock" pill)
|
|
55
|
+
this.agentsOpen = localStorage.getItem("tm.agentsOpen") !== "0"; // agent-load bar open/closed
|
|
56
|
+
this.project = localStorage.getItem("tm.project") || ""; // currently selected project id
|
|
57
|
+
|
|
58
|
+
this.projectStore = new ProjectStore();
|
|
59
|
+
this.taskStore = new TaskStore(() => this.dom.src.value.trim());
|
|
60
|
+
this.contextStore = new ContextStore(() => this.dom.src.value);
|
|
61
|
+
this.api = new ApiClient(() => this.dom.actor.value, () => this.project); // write bridge (api/index.php)
|
|
62
|
+
this.boardView = new BoardView({ board: this.dom.board, stats: this.dom.stats, agents: this.dom.agents, agentsCount: this.dom.agentsCount });
|
|
63
|
+
this.taskModal = new TaskModal({ overlay: this.dom.overlay, mTitle: this.dom.mTitle, mBody: this.dom.mBody, mClose: this.dom.mClose });
|
|
64
|
+
this.contextPanel = new ContextPanel({ ctxBtn: this.dom.ctxBtn, ctxOverlay: this.dom.ctxOverlay, ctxClose: this.dom.ctxClose, ctxBody: this.dom.ctxBody, ctxUpdated: this.dom.ctxUpdated });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- URL / localStorage sync ----
|
|
68
|
+
readState() {
|
|
69
|
+
const s = UrlState.read();
|
|
70
|
+
this.dom.q.value = s.q;
|
|
71
|
+
this.sort = s.sort;
|
|
72
|
+
this.view = s.view;
|
|
73
|
+
this.compact = s.compact;
|
|
74
|
+
this.agentFilter = s.agentFilter;
|
|
75
|
+
this.moduleFilter = s.moduleFilter;
|
|
76
|
+
this.quickFilter = s.quickFilter;
|
|
77
|
+
this.pendingTask = s.pendingTask;
|
|
78
|
+
this.collapsedCols = s.collapsedCols;
|
|
79
|
+
if (s.interval) this.dom.interval.value = s.interval;
|
|
80
|
+
this.dom.actor.value = localStorage.getItem("tm.actor") || "";
|
|
81
|
+
this.dom.sort.value = this.sort;
|
|
82
|
+
this.setViewButtons();
|
|
83
|
+
this.setQuickButtons();
|
|
84
|
+
this.dom.compact.classList.toggle("on", this.compact);
|
|
85
|
+
this.setAgentsOpen(this.agentsOpen);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
syncURL() {
|
|
89
|
+
UrlState.sync({
|
|
90
|
+
q: this.dom.q.value, agentFilter: this.agentFilter, moduleFilter: this.moduleFilter, quickFilter: this.quickFilter, sort: this.sort,
|
|
91
|
+
view: this.view, compact: this.compact, openTaskId: this.openTaskId, project: this.project, lang: I18n.lang,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setStatus(state, text) {
|
|
96
|
+
this.dom.dot.className = "dot" + (state ? " " + state : "");
|
|
97
|
+
this.dom.dot.classList.remove("pulse"); void this.dom.dot.offsetWidth; this.dom.dot.classList.add("pulse");
|
|
98
|
+
this.dom.statusText.textContent = text;
|
|
99
|
+
}
|
|
100
|
+
showBanner(msg) {
|
|
101
|
+
if (!msg) { this.dom.banner.classList.remove("show"); return; }
|
|
102
|
+
this.dom.banner.innerHTML = msg;
|
|
103
|
+
this.dom.banner.classList.add("show");
|
|
104
|
+
}
|
|
105
|
+
setViewButtons() {
|
|
106
|
+
this.dom.viewBoard.classList.toggle("on", this.view === "board");
|
|
107
|
+
this.dom.viewSwim.classList.toggle("on", this.view === "swim");
|
|
108
|
+
this.dom.viewFeed.classList.toggle("on", this.view === "feed");
|
|
109
|
+
}
|
|
110
|
+
setQuickButtons() {
|
|
111
|
+
this.dom.qfReview.classList.toggle("on", this.quickFilter === "review");
|
|
112
|
+
this.dom.qfActive.classList.toggle("on", this.quickFilter === "active");
|
|
113
|
+
this.dom.qfBlocked.classList.toggle("on", this.quickFilter === "blocked");
|
|
114
|
+
}
|
|
115
|
+
toggleQuick(v) {
|
|
116
|
+
this.quickFilter = this.quickFilter === v ? null : v;
|
|
117
|
+
this.setQuickButtons(); this.render(); this.syncURL();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Agent-load bar collapse/expand (the mockup's collapsible header) ----
|
|
121
|
+
setAgentsOpen(open) {
|
|
122
|
+
this.agentsOpen = open;
|
|
123
|
+
this.dom.agentsWrap.classList.toggle("open", open);
|
|
124
|
+
this.dom.agentsHead.setAttribute("aria-expanded", String(open));
|
|
125
|
+
localStorage.setItem("tm.agentsOpen", open ? "1" : "0");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- "Source" select: a real project switcher across the projects registered in
|
|
129
|
+
// data/projects.json (see populateProjectSelect / applyProject). ----
|
|
130
|
+
|
|
131
|
+
// Populates <select id="srcProject"> with the loaded projects, and applies the initial
|
|
132
|
+
// selection (URL ?project=, then localStorage tm.project, then the first available one).
|
|
133
|
+
populateProjectSelect() {
|
|
134
|
+
const projects = this.projectStore.projects;
|
|
135
|
+
this.dom.srcProject.innerHTML = projects
|
|
136
|
+
.map(p => `<option value="${Utils.esc(p.id)}">${Utils.esc(p.label)}</option>`)
|
|
137
|
+
.join("");
|
|
138
|
+
if (!projects.length) return;
|
|
139
|
+
if (!projects.some(p => p.id === this.project)) this.project = projects[0].id;
|
|
140
|
+
this.dom.srcProject.value = this.project;
|
|
141
|
+
this.applyProject(this.project, { silent: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Project switch: sets the src (tasks.json) URL, saves it to localStorage + the URL
|
|
145
|
+
// (?project=), and (unless silent) immediately re-polls the new source.
|
|
146
|
+
applyProject(id, { silent = false } = {}) {
|
|
147
|
+
this.project = id;
|
|
148
|
+
localStorage.setItem("tm.project", id);
|
|
149
|
+
this.dom.src.value = `data/${id}/tasks.json`;
|
|
150
|
+
UrlState.setSrc(this.dom.src.value);
|
|
151
|
+
this.syncURL();
|
|
152
|
+
if (!silent) { this.resetSource(); this.poll(); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Header's live clock (decorative, matches the mockup's ticking clock) ----
|
|
156
|
+
tickClock() { this.dom.clock.textContent = new Date().toLocaleTimeString(I18n.locale()); }
|
|
157
|
+
|
|
158
|
+
// ---- Language toggle (EN default, HU alternative) ----
|
|
159
|
+
toggleLang() {
|
|
160
|
+
I18n.setLang(I18n.lang === "hu" ? "en" : "hu");
|
|
161
|
+
this.applyStaticI18n();
|
|
162
|
+
this.tickClock();
|
|
163
|
+
this.render();
|
|
164
|
+
this.syncURL();
|
|
165
|
+
if (this.openTaskId) this.openModal(this.openTaskId);
|
|
166
|
+
if (this.dom.ctxOverlay.classList.contains("show")) this.contextPanel.renderBody(this.contextStore.context);
|
|
167
|
+
if (this.dom.projectsOverlay.classList.contains("show")) this.openProjects();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Applies the current language to static markup: elements with data-i18n get their
|
|
171
|
+
// textContent set, data-i18n-placeholder their placeholder, data-i18n-title their title.
|
|
172
|
+
applyStaticI18n() {
|
|
173
|
+
document.documentElement.lang = I18n.lang;
|
|
174
|
+
if (this.dom.langBtn) this.dom.langBtn.textContent = I18n.lang === "hu" ? "EN" : "HU";
|
|
175
|
+
document.querySelectorAll("[data-i18n]").forEach(e => { e.textContent = I18n.t(e.dataset.i18n); });
|
|
176
|
+
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => { e.placeholder = I18n.t(e.dataset.i18nPlaceholder); });
|
|
177
|
+
document.querySelectorAll("[data-i18n-title]").forEach(e => { e.title = I18n.t(e.dataset.i18nTitle); });
|
|
178
|
+
this.dom.toggle.querySelector(".btxt").textContent = I18n.t(this.running ? "hd.pause" : "hd.resume");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
render() {
|
|
182
|
+
this.boardView.render(this.taskStore.currentTasks, {
|
|
183
|
+
q: this.dom.q.value, agentFilter: this.agentFilter, moduleFilter: this.moduleFilter, quickFilter: this.quickFilter, sort: this.sort,
|
|
184
|
+
view: this.view, compact: this.compact, collapsedCols: this.collapsedCols,
|
|
185
|
+
changeInfo: this.taskStore.changeInfo,
|
|
186
|
+
});
|
|
187
|
+
this.syncActorList();
|
|
188
|
+
this.syncModuleFilterOptions();
|
|
189
|
+
this.tickRelTimes();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// #6 Live relative time: instead of a full re-render, only updates the .js-rel / .js-wait span text.
|
|
193
|
+
tickRelTimes() {
|
|
194
|
+
document.querySelectorAll(".js-rel[data-ts]").forEach(e => { e.textContent = Utils.relTime(e.dataset.ts); });
|
|
195
|
+
document.querySelectorAll(".js-wait[data-ts]").forEach(e => {
|
|
196
|
+
e.textContent = "⏳ " + Utils.relTime(e.dataset.ts);
|
|
197
|
+
e.className = "badge await await-" + Utils.waitLevel(Utils.ageMs(e.dataset.ts)) + " js-wait";
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Known agent names from the current tasks (for the actor- and assignment-datalist).
|
|
202
|
+
agentsList() {
|
|
203
|
+
const set = new Set();
|
|
204
|
+
this.taskStore.currentTasks.forEach(t => { if (t.assignedAgentId) set.add(t.assignedAgentId); });
|
|
205
|
+
return [...set].sort();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Known module names from the current tasks (for the module filter + assignment-datalist).
|
|
209
|
+
modulesList() {
|
|
210
|
+
const set = new Set();
|
|
211
|
+
this.taskStore.currentTasks.forEach(t => { if (t.module) set.add(t.module); });
|
|
212
|
+
return [...set].sort();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Populates the header's "As …" datalist with known agents (+ a couple of base roles).
|
|
216
|
+
syncActorList() {
|
|
217
|
+
if (!this.dom.actorList) return;
|
|
218
|
+
const extra = ["reviewer", "main"];
|
|
219
|
+
const all = [...new Set([...this.agentsList(), ...extra])];
|
|
220
|
+
this.dom.actorList.innerHTML = all.map(a => `<option value="${Utils.esc(a)}"></option>`).join("");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Populates the <select id="moduleFilter"> with known modules, preserving the current selection.
|
|
224
|
+
syncModuleFilterOptions() {
|
|
225
|
+
if (!this.dom.moduleFilter) return;
|
|
226
|
+
const modules = this.modulesList();
|
|
227
|
+
const cur = this.moduleFilter || "";
|
|
228
|
+
this.dom.moduleFilter.innerHTML =
|
|
229
|
+
`<option value="">${Utils.esc(I18n.t("ctrl.module.all"))}</option>` +
|
|
230
|
+
modules.map(m => `<option value="${Utils.esc(m)}"${m === cur ? " selected" : ""}>${Utils.esc(m)}</option>`).join("");
|
|
231
|
+
this.dom.moduleFilter.value = cur;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- Modal ----
|
|
235
|
+
openModal(id) {
|
|
236
|
+
const t = this.taskStore.currentTasks.find(x => x.id === id); if (!t) return;
|
|
237
|
+
this.openTaskId = id; this.syncURL();
|
|
238
|
+
this.taskModal.render(t, this.boardView.teamIndex, this.taskStore.currentTasks, {
|
|
239
|
+
writeEnabled: this.api.enabled, agents: this.agentsList(), modules: this.modulesList(),
|
|
240
|
+
});
|
|
241
|
+
this.taskModal.show();
|
|
242
|
+
}
|
|
243
|
+
closeModal() {
|
|
244
|
+
this.taskModal.hide();
|
|
245
|
+
this.openTaskId = null; this.syncURL();
|
|
246
|
+
}
|
|
247
|
+
openByTeam(n) {
|
|
248
|
+
const t = this.boardView.teamIndex.get(n);
|
|
249
|
+
if (t) this.openModal(t.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- Writing through the api/index.php bridge (task.sh), then an immediate re-poll ----
|
|
253
|
+
async runOps(ops, okMsg) {
|
|
254
|
+
try {
|
|
255
|
+
this.setStatus("", I18n.t("app.sending"));
|
|
256
|
+
await this.api.run(ops);
|
|
257
|
+
await this.poll(); // for the canonical state (task.sh is the source of truth)
|
|
258
|
+
if (this.openTaskId) this.openModal(this.openTaskId); // modal re-renders with fresh data
|
|
259
|
+
this.setStatus("ok", okMsg || I18n.t("app.done", { t: new Date().toLocaleTimeString(I18n.locale()) }));
|
|
260
|
+
this.showBanner(null);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
this.setStatus("err", I18n.t("app.writeError"));
|
|
263
|
+
this.showBanner(I18n.t("app.writeFailed", { msg: Utils.esc(e.message) }));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Primary action button in the modal (review approve/changes/block, status, note).
|
|
268
|
+
applyAction(act) {
|
|
269
|
+
const id = this.openTaskId; if (!id) return;
|
|
270
|
+
const noteEl = this.dom.mBody.querySelector("#actNote");
|
|
271
|
+
const note = noteEl ? noteEl.value.trim() : "";
|
|
272
|
+
switch (act) {
|
|
273
|
+
case "approve": return this.runOps([{ cmd: "status", args: [id, "done"] }], I18n.t("app.approved"));
|
|
274
|
+
case "done": return this.runOps([{ cmd: "status", args: [id, "done"] }], I18n.t("app.toDone"));
|
|
275
|
+
case "start": return this.runOps([{ cmd: "status", args: [id, "in_progress"] }], I18n.t("app.toInProgress"));
|
|
276
|
+
case "review": return this.runOps([{ cmd: "status", args: [id, "review"] }], I18n.t("app.toReview"));
|
|
277
|
+
case "reopen": return this.runOps([{ cmd: "reopen", args: [id] }], I18n.t("app.reopened"));
|
|
278
|
+
case "changes":
|
|
279
|
+
if (!note) { this.showBanner(I18n.t("app.needFeedback")); return; }
|
|
280
|
+
// note → in_progress: the feedback goes into the affected agent's inbox (events.jsonl).
|
|
281
|
+
// (The current UI language is sent separately with every write — see ApiClient — and
|
|
282
|
+
// surfaced to the agent when it next runs task.sh, not stored in the note text itself.)
|
|
283
|
+
return this.runOps([
|
|
284
|
+
{ cmd: "note", args: [id, "REVIEW: " + note] },
|
|
285
|
+
{ cmd: "status", args: [id, "in_progress"] },
|
|
286
|
+
], I18n.t("app.changesRequested"));
|
|
287
|
+
case "block":
|
|
288
|
+
return this.runOps(
|
|
289
|
+
note ? [{ cmd: "note", args: [id, "BLOCK: " + note] }, { cmd: "status", args: [id, "blocked"] }]
|
|
290
|
+
: [{ cmd: "status", args: [id, "blocked"] }],
|
|
291
|
+
I18n.t("app.toBlocked"));
|
|
292
|
+
case "note":
|
|
293
|
+
if (!note) { this.showBanner(I18n.t("app.needNoteText")); return; }
|
|
294
|
+
return this.runOps([{ cmd: "note", args: [id, note] }], I18n.t("app.noteAdded"));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Inline field edit in the modal (priority / module / assignment).
|
|
299
|
+
applyField(field, value) {
|
|
300
|
+
const id = this.openTaskId; if (!id) return;
|
|
301
|
+
const v = (value || "").trim();
|
|
302
|
+
if (field === "priority") return this.runOps([{ cmd: "priority", args: [id, v] }], I18n.t("app.priority", { v }));
|
|
303
|
+
if (field === "module") return this.runOps([{ cmd: "module", args: [id, v] }], I18n.t("app.module", { v: v || "—" }));
|
|
304
|
+
if (field === "assign") {
|
|
305
|
+
if (!v) return;
|
|
306
|
+
return this.runOps([{ cmd: "assign", args: [id, v] }], I18n.t("app.assigned", { v }));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- Copy to clipboard (with feedback; file:// fallback) ----
|
|
311
|
+
async copyToClipboard(text, btn) {
|
|
312
|
+
let ok = false;
|
|
313
|
+
try {
|
|
314
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
315
|
+
await navigator.clipboard.writeText(text); ok = true;
|
|
316
|
+
}
|
|
317
|
+
} catch { /* fallback below */ }
|
|
318
|
+
if (!ok) {
|
|
319
|
+
try {
|
|
320
|
+
const ta = document.createElement("textarea");
|
|
321
|
+
ta.value = text; ta.setAttribute("readonly", "");
|
|
322
|
+
ta.style.position = "fixed"; ta.style.opacity = "0";
|
|
323
|
+
document.body.appendChild(ta); ta.select();
|
|
324
|
+
ok = document.execCommand("copy");
|
|
325
|
+
document.body.removeChild(ta);
|
|
326
|
+
} catch { ok = false; }
|
|
327
|
+
}
|
|
328
|
+
if (btn) {
|
|
329
|
+
const orig = btn.textContent;
|
|
330
|
+
btn.textContent = ok ? I18n.t("app.copied") : I18n.t("app.copyFailed");
|
|
331
|
+
btn.classList.toggle("copied", ok);
|
|
332
|
+
clearTimeout(btn._copyTimer);
|
|
333
|
+
btn._copyTimer = setTimeout(() => {
|
|
334
|
+
btn.textContent = orig; btn.classList.remove("copied");
|
|
335
|
+
}, 1400);
|
|
336
|
+
}
|
|
337
|
+
return ok;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- Projects modal (registered projects + wrapper task.sh copying) ----
|
|
341
|
+
openProjects() {
|
|
342
|
+
const projects = this.projectStore.projects;
|
|
343
|
+
this.dom.projectsBody.innerHTML = projects.length
|
|
344
|
+
? projects.map(p => `
|
|
345
|
+
<div class="proj-row">
|
|
346
|
+
<div class="proj-row-main">
|
|
347
|
+
<div class="proj-row-label">${Utils.esc(p.label)}</div>
|
|
348
|
+
<div class="proj-row-id mut">${Utils.esc(p.id)} · ${Utils.esc(p.dataDir)}</div>
|
|
349
|
+
</div>
|
|
350
|
+
<button type="button" class="copy-wrapper" data-project="${Utils.esc(p.id)}">${Utils.esc(I18n.t("project.wrapperCopy"))}</button>
|
|
351
|
+
</div>`).join("")
|
|
352
|
+
: `<p class="mut">${I18n.t("project.none")}</p>`;
|
|
353
|
+
this.dom.projectsOverlay.classList.add("show");
|
|
354
|
+
}
|
|
355
|
+
closeProjects() { this.dom.projectsOverlay.classList.remove("show"); }
|
|
356
|
+
async copyWrapper(id, btn) {
|
|
357
|
+
try {
|
|
358
|
+
const res = await fetch(`wrappers/${id}.sh`, { cache: "no-store" });
|
|
359
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
360
|
+
const text = await res.text();
|
|
361
|
+
await this.copyToClipboard(text, btn);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
this.showBanner(I18n.t("app.wrapperLoadFailed", { msg: Utils.esc(e.message) }));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---- User context (session continuity) ----
|
|
368
|
+
async pollContext() {
|
|
369
|
+
const { changed } = await this.contextStore.poll();
|
|
370
|
+
if (!changed) return;
|
|
371
|
+
this.contextPanel.renderButton(this.contextStore.context);
|
|
372
|
+
if (this.dom.ctxOverlay.classList.contains("show")) this.contextPanel.renderBody(this.contextStore.context);
|
|
373
|
+
}
|
|
374
|
+
openCtx() { this.contextPanel.renderBody(this.contextStore.context); this.contextPanel.show(); }
|
|
375
|
+
closeCtx() { this.contextPanel.hide(); }
|
|
376
|
+
|
|
377
|
+
// ---- #3 Browser notification + title badge ----
|
|
378
|
+
async toggleNotify() {
|
|
379
|
+
if (!this.notify) {
|
|
380
|
+
if (!("Notification" in window)) { this.setStatus("err", "Notifications are not supported by this browser"); return; }
|
|
381
|
+
let perm = Notification.permission;
|
|
382
|
+
if (perm === "default") perm = await Notification.requestPermission();
|
|
383
|
+
if (perm !== "granted") { this.setStatus("err", "Notifications are disabled in the browser"); return; }
|
|
384
|
+
this.notify = true;
|
|
385
|
+
} else {
|
|
386
|
+
this.notify = false;
|
|
387
|
+
}
|
|
388
|
+
this.dom.notifyBtn.innerHTML = ICONS.bell;
|
|
389
|
+
this.dom.notifyBtn.classList.toggle("on", this.notify);
|
|
390
|
+
}
|
|
391
|
+
handleNotifications(tasks) {
|
|
392
|
+
if (!this.notify || !("Notification" in window) || Notification.permission !== "granted") return;
|
|
393
|
+
const ci = this.taskStore.changeInfo; // only has transitions from the 2nd poll on (empty on first load)
|
|
394
|
+
tasks.forEach(t => {
|
|
395
|
+
const c = ci.get(t.id);
|
|
396
|
+
if (!c || !c.status) return;
|
|
397
|
+
const to = String(c.status).split("→")[1];
|
|
398
|
+
if (to !== "review" && to !== "done") return;
|
|
399
|
+
new Notification(`Task ${to === "review" ? "awaiting review" : "done"}: ${t.title || t.id}`, {
|
|
400
|
+
body: (t.assignedAgentId ? "👤 " + t.assignedAgentId + " · " : "") + c.status,
|
|
401
|
+
tag: "tm-" + t.id,
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
updateTitle(tasks) {
|
|
406
|
+
const review = tasks.filter(t => !t.isArchived && t.status === "review").length;
|
|
407
|
+
document.title = (review ? I18n.t("app.title.review", { n: review }) + " " : "") + I18n.t("app.title");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- Polling (conditional GET) ----
|
|
411
|
+
async poll() {
|
|
412
|
+
if (!this.project) return;
|
|
413
|
+
this.pollContext(); // the user context is fetched independently, in parallel
|
|
414
|
+
try {
|
|
415
|
+
const result = await this.taskStore.poll();
|
|
416
|
+
if (result.notModified) {
|
|
417
|
+
this.setStatus("ok", I18n.t("app.noChange", { t: new Date().toLocaleTimeString(I18n.locale()) }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.showBanner(null);
|
|
421
|
+
|
|
422
|
+
this.handleNotifications(result.tasks); // #3 notification for new review/done transitions
|
|
423
|
+
this.updateTitle(result.tasks); // #3 title badge for tasks awaiting review
|
|
424
|
+
if (result.shouldRender) { this.render(); this.taskStore.markRendered(); }
|
|
425
|
+
if (this.pendingTask) { this.openModal(this.pendingTask); this.pendingTask = null; }
|
|
426
|
+
|
|
427
|
+
const n = result.tasks.length;
|
|
428
|
+
this.setStatus("ok", I18n.t("app.live", { n, s: n === 1 ? "" : "s", t: new Date().toLocaleTimeString(I18n.locale()) }) + (result.changeCount ? I18n.t("app.liveChanged", { n: result.changeCount }) : ""));
|
|
429
|
+
} catch (err) {
|
|
430
|
+
this.setStatus("err", "error: " + err.message);
|
|
431
|
+
if (String(err.message).includes("Failed to fetch") || err instanceof TypeError)
|
|
432
|
+
this.showBanner(I18n.t("app.fetchFailed"));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
schedule() {
|
|
437
|
+
if (this.timer) clearInterval(this.timer);
|
|
438
|
+
if (this.running && !document.hidden) this.timer = setInterval(() => this.poll(), parseInt(this.dom.interval.value, 10) || 2000);
|
|
439
|
+
}
|
|
440
|
+
resetSource() {
|
|
441
|
+
this.taskStore.reset();
|
|
442
|
+
this.contextStore.reset();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---- Events ----
|
|
446
|
+
bindEvents() {
|
|
447
|
+
const dom = this.dom;
|
|
448
|
+
|
|
449
|
+
dom.toggle.addEventListener("click", () => {
|
|
450
|
+
this.running = !this.running; dom.toggle.innerHTML = (this.running ? ICONS.pause : ICONS.play) + ` <span class="btxt">${I18n.t(this.running ? "hd.pause" : "hd.resume")}</span>`;
|
|
451
|
+
if (this.running) { this.poll(); this.schedule(); } else { clearInterval(this.timer); this.timer = null; this.setStatus("idle", I18n.t("app.paused")); }
|
|
452
|
+
});
|
|
453
|
+
dom.refresh.addEventListener("click", () => this.poll());
|
|
454
|
+
dom.q.addEventListener("input", () => { this.render(); this.syncURL(); });
|
|
455
|
+
dom.srcProject.addEventListener("change", () => this.applyProject(dom.srcProject.value));
|
|
456
|
+
dom.agentsHead.addEventListener("click", () => this.setAgentsOpen(!this.agentsOpen));
|
|
457
|
+
dom.interval.addEventListener("change", () => { UrlState.setInterval(dom.interval.value); this.schedule(); });
|
|
458
|
+
dom.sort.addEventListener("change", () => { this.sort = dom.sort.value; UrlState.setSort(this.sort); this.render(); this.syncURL(); });
|
|
459
|
+
dom.viewBoard.addEventListener("click", () => { this.view = "board"; UrlState.setView(this.view); this.setViewButtons(); this.render(); this.syncURL(); });
|
|
460
|
+
dom.viewSwim.addEventListener("click", () => { this.view = "swim"; UrlState.setView(this.view); this.setViewButtons(); this.render(); this.syncURL(); });
|
|
461
|
+
dom.viewFeed.addEventListener("click", () => { this.view = "feed"; UrlState.setView(this.view); this.setViewButtons(); this.render(); this.syncURL(); });
|
|
462
|
+
dom.compact.addEventListener("click", () => { this.compact = !this.compact; UrlState.setCompact(this.compact); dom.compact.classList.toggle("on", this.compact); this.render(); this.syncURL(); });
|
|
463
|
+
dom.qfReview.addEventListener("click", () => this.toggleQuick("review"));
|
|
464
|
+
dom.qfActive.addEventListener("click", () => this.toggleQuick("active"));
|
|
465
|
+
dom.qfBlocked.addEventListener("click", () => this.toggleQuick("blocked"));
|
|
466
|
+
dom.moduleFilter.addEventListener("change", () => { this.moduleFilter = dom.moduleFilter.value || null; this.render(); this.syncURL(); });
|
|
467
|
+
dom.notifyBtn.addEventListener("click", () => this.toggleNotify());
|
|
468
|
+
dom.projectsBtn.addEventListener("click", () => this.openProjects());
|
|
469
|
+
dom.projectsClose.addEventListener("click", () => this.closeProjects());
|
|
470
|
+
dom.projectsOverlay.addEventListener("click", e => { if (e.target === dom.projectsOverlay) this.closeProjects(); });
|
|
471
|
+
dom.projectsBody.addEventListener("click", e => {
|
|
472
|
+
const b = e.target.closest(".copy-wrapper"); if (!b) return;
|
|
473
|
+
this.copyWrapper(b.dataset.project, b);
|
|
474
|
+
});
|
|
475
|
+
dom.langBtn.addEventListener("click", () => this.toggleLang());
|
|
476
|
+
dom.agents.addEventListener("click", e => {
|
|
477
|
+
const btn = e.target.closest(".agent-chip"); if (!btn) return;
|
|
478
|
+
const a = btn.dataset.agent;
|
|
479
|
+
const all = Object.keys(this.taskStore.currentTasks.filter(t => !t.isArchived).reduce((o, t) => (o[t.assignedAgentId || "—"] = 1, o), {}));
|
|
480
|
+
if (a === "__all__") this.agentFilter = null;
|
|
481
|
+
else if (this.agentFilter === null) { this.agentFilter = new Set(all); this.agentFilter.delete(a); }
|
|
482
|
+
else { this.agentFilter.has(a) ? this.agentFilter.delete(a) : this.agentFilter.add(a); if (this.agentFilter.size === all.length) this.agentFilter = null; }
|
|
483
|
+
this.render(); this.syncURL();
|
|
484
|
+
});
|
|
485
|
+
dom.board.addEventListener("click", e => {
|
|
486
|
+
const dep = e.target.closest(".badge.dep-active, .badge.dep-done");
|
|
487
|
+
if (dep && dep.dataset.team) { e.stopPropagation(); this.openByTeam(+dep.dataset.team); return; }
|
|
488
|
+
// Clicking a column header does NOT collapse the column (per user request).
|
|
489
|
+
if (e.target.closest(".col-head")) return;
|
|
490
|
+
const fi = e.target.closest(".feed-item"); if (fi) { this.openModal(fi.dataset.id); return; }
|
|
491
|
+
const c = e.target.closest(".card"); if (c) this.openModal(c.dataset.id);
|
|
492
|
+
});
|
|
493
|
+
dom.mBody.addEventListener("click", e => {
|
|
494
|
+
const ab = e.target.closest(".act-btn");
|
|
495
|
+
if (ab) { e.preventDefault(); this.applyAction(ab.dataset.act); return; }
|
|
496
|
+
const cp = e.target.closest(".copy-id");
|
|
497
|
+
if (cp) { this.copyToClipboard(cp.dataset.copy || "", cp); return; }
|
|
498
|
+
const t = e.target.closest(".deplink"); if (!t) return;
|
|
499
|
+
if (t.dataset.team) this.openByTeam(+t.dataset.team);
|
|
500
|
+
else if (t.dataset.task) this.openModal(t.dataset.task);
|
|
501
|
+
});
|
|
502
|
+
// Inline field edit (priority/module select, assignment input) in the modal.
|
|
503
|
+
dom.mBody.addEventListener("change", e => {
|
|
504
|
+
const f = e.target.closest(".act-input"); if (!f) return;
|
|
505
|
+
this.applyField(f.dataset.field, f.value);
|
|
506
|
+
});
|
|
507
|
+
// Persist the actor ("As …") across sessions.
|
|
508
|
+
dom.actor.addEventListener("change", () => localStorage.setItem("tm.actor", dom.actor.value.trim()));
|
|
509
|
+
dom.mClose.addEventListener("click", () => this.closeModal());
|
|
510
|
+
dom.overlay.addEventListener("click", e => { if (e.target === dom.overlay) this.closeModal(); });
|
|
511
|
+
dom.ctxBtn.addEventListener("click", () => this.openCtx());
|
|
512
|
+
dom.ctxClose.addEventListener("click", () => this.closeCtx());
|
|
513
|
+
dom.ctxOverlay.addEventListener("click", e => { if (e.target === dom.ctxOverlay) this.closeCtx(); });
|
|
514
|
+
document.addEventListener("keydown", e => { if (e.key === "Escape") { this.closeModal(); this.closeCtx(); this.closeProjects(); } });
|
|
515
|
+
|
|
516
|
+
// Auto-pause in a background tab
|
|
517
|
+
document.addEventListener("visibilitychange", () => {
|
|
518
|
+
if (document.hidden) { clearInterval(this.timer); this.timer = null; if (this.running) this.setStatus("idle", I18n.t("app.background")); }
|
|
519
|
+
else if (this.running) { this.poll(); this.schedule(); }
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async init() {
|
|
524
|
+
// The URL's ?project= and ?lang= (if present) win over localStorage — this is what
|
|
525
|
+
// makes the board's URL directly shareable/bookmarkable to a specific project/language,
|
|
526
|
+
// and is also how a Claude Code agent can be pointed at the right project + language.
|
|
527
|
+
const s0 = UrlState.read();
|
|
528
|
+
if (s0.lang) I18n.setLang(s0.lang);
|
|
529
|
+
if (s0.project) { this.project = s0.project; localStorage.setItem("tm.project", this.project); }
|
|
530
|
+
this.applyStaticI18n();
|
|
531
|
+
await this.projectStore.load();
|
|
532
|
+
this.populateProjectSelect();
|
|
533
|
+
this.readState();
|
|
534
|
+
this.bindEvents();
|
|
535
|
+
if (this.projectStore.projects.length) {
|
|
536
|
+
this.poll();
|
|
537
|
+
this.schedule();
|
|
538
|
+
} else {
|
|
539
|
+
this.setStatus("err", I18n.t("app.noProjectShort"));
|
|
540
|
+
this.showBanner(I18n.t("app.noProjectRegistered"));
|
|
541
|
+
}
|
|
542
|
+
// #6 Refreshes relative times every 30s without a full re-render.
|
|
543
|
+
this.relTimer = setInterval(() => this.tickRelTimes(), 30000);
|
|
544
|
+
this.tickClock();
|
|
545
|
+
this.clockTimer = setInterval(() => this.tickClock(), 1000);
|
|
546
|
+
}
|
|
547
|
+
}
|