@songsid/agend 2.3.0-beta.19 → 2.3.0-beta.20
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/dist/agent-cli-instructions.md +4 -0
- package/dist/agent-cli.js +25 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/config-validator.js +19 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.js +4 -0
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +26 -0
- package/dist/fleet-manager.js +115 -14
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +25 -1
- package/dist/instance-config-impact.js +4 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +20 -4
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +33 -0
- package/dist/outbound-schemas.js +25 -8
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +5 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/scheduler/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +135 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +650 -0
- package/dist/scheduler/task-board.js +1938 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +141 -0
- package/dist/search/search-index.js +440 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +154 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +44 -0
- package/dist/task-board-waker.js +128 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +448 -0
- package/dist/ui/fleet-tasks.js +535 -0
- package/dist/ui/panel-chat.js +8 -0
- package/dist/ui/panel-fleet.js +21 -65
- package/dist/ui/search-dialog.js +349 -0
- package/dist/ui/shared/app-i18n.js +3 -0
- package/dist/ui/shared/app-shell.js +43 -1
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +270 -2
- package/dist/ui/shared/app.js +3 -1
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/web-api.d.ts +14 -4
- package/dist/web-api.js +408 -10
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +2 -0
- package/dist/web-chat-history.js +2 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/workflow-templates/default.md +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// One card of the task board (Fleet › Tasks), in the app's dialog: every field editable in place, the checklist, the
|
|
2
|
+
// moves the person makes (confirm, send back, answer, move, cancel, reopen), its sub tasks, and its comments, progress
|
|
3
|
+
// log and history. Edits carry the version they started from, so saving over an agent's newer write is caught (409):
|
|
4
|
+
// the dialog then shows the newer card with the draft still open, and saving again overwrites on purpose.
|
|
5
|
+
import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
|
|
6
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
7
|
+
import { appStore, useStore } from "/assets/app-store.js";
|
|
8
|
+
import { Dialog } from "/assets/ui-dialog.js";
|
|
9
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
10
|
+
import { toast } from "/assets/ui-toast.js";
|
|
11
|
+
import { StatusPill, answerBlocked, askText, boardApi, currentWork, errorText, followsSubs, moveCard, notesForPeople, relTime, wrapUpText } from "./fleet-task-common.js";
|
|
12
|
+
|
|
13
|
+
register("taskcard", {
|
|
14
|
+
en: {
|
|
15
|
+
loading: "Loading…", missing: "There is no card {0}", version: "v{0}", copyLink: "Copy this card's link", linkCopied: "Link copied",
|
|
16
|
+
conflict: "{0} changed while you were editing. The newer card is shown and your edit is still open — save again to overwrite it.",
|
|
17
|
+
moveTo: "Move to…", moveLabel: "Change the status", followsSubs: "It has sub tasks: its status follows them",
|
|
18
|
+
reopen: "Reopen", cancelCard: "Cancel this card", confirmMain: "Confirm the main task",
|
|
19
|
+
assignee: "Assignee", nobody: "Unassigned", unassign: "(unassign)", other: "Other…", otherTitle: "Assign to another instance", otherLabel: "The instance's name",
|
|
20
|
+
change: "Change…", project: "Project", priority: "Priority", size: "Size", review: "Your confirmation", reviewBox: "Stop at review for my confirmation",
|
|
21
|
+
reviewOn: "Stops at review for your confirmation", reviewOff: "Finishes without your confirmation",
|
|
22
|
+
parent: "Main task", deps: "Waits for", none: "None", edit: "Edit", depsTitle: "What {0} waits for", depsLabel: "Card numbers to finish first, separated by commas or spaces (empty: nothing)",
|
|
23
|
+
depWaiting: "Not done yet", depDone: "Done", opened: "Opened by {0} {1} · changed {2}",
|
|
24
|
+
blockedReason: "Blocked on", goal: "Goal", goalHint: "Why, and what done looks like", scope: "Scope", scopeHint: "In: … / Out: …",
|
|
25
|
+
definition: "Definition of done", acceptance: "Acceptance", context: "Context", contextHint: "Paths, related cards, decisions already made",
|
|
26
|
+
state: "State", stateHint: "Done / next / watch out (the assignee keeps it current)", result: "Result", resultHint: "Which files changed and how it was checked",
|
|
27
|
+
empty: "(empty — click to edit)", clickToEdit: "Click to edit", ctrlEnter: "Ctrl+Enter saves", save: "Save", cancel: "Cancel",
|
|
28
|
+
checklistEmpty: "(none yet — click to add)", editList: "Edit the list", onePerLine: "One per line; an item whose text changes is unchecked again", items: "{0}/{1} items",
|
|
29
|
+
subs: "Sub tasks ({0})", quickAdd: "New sub task: type a title and press Enter (small work needs no split)",
|
|
30
|
+
tabComments: "Comments {0}", tabLog: "Progress log {0}", tabHistory: "History", noComments: "No comments yet", noLog: "No progress log yet",
|
|
31
|
+
rejectTag: "Sent back", approveTag: "Confirmed", commentTo: "Comment to {0} (Ctrl+Enter sends)", commentHint: "Comment (Ctrl+Enter sends)", send: "Send",
|
|
32
|
+
evCreated: "opened it", evMoved: "{0} → {1}", evAuto: " (automatic)", evAssigned: "assignee {0} → {1}", evUpdated: "changed {0}", evCommented: "commented", evLogged: "log: {0}",
|
|
33
|
+
fTitle: "title", fGoal: "goal", fScope: "scope", fContext: "context", fState: "state", fResult: "result", fBlockedReason: "blocked reason", fSize: "size",
|
|
34
|
+
fPriority: "priority", fNeedsReview: "needs review", fDependsOn: "dependencies", fAcceptance: "acceptance", fProjectId: "project",
|
|
35
|
+
position: "Position", moveUp: "Move up", moveDown: "Move down",
|
|
36
|
+
dispatcher: "Dispatched by", dispatcherHint: "A blocked card's question goes to them first", dispatcherMe: "Me", dispatcherNone: "Nobody",
|
|
37
|
+
dispatcherPerson: "{0} (a person)", now: "Now", nowNone: "No sub task in progress", fDispatcher: "dispatcher",
|
|
38
|
+
},
|
|
39
|
+
"zh-TW": {
|
|
40
|
+
loading: "載入中…", missing: "找不到 {0}", version: "v{0}", copyLink: "複製這張卡的連結", linkCopied: "已複製連結",
|
|
41
|
+
conflict: "{0} 在你編輯時被改過了。已載入最新內容,你的修改還在,再存一次就會覆蓋。",
|
|
42
|
+
moveTo: "移到…", moveLabel: "改狀態", followsSubs: "有子任務,狀態會跟著子任務自動變化",
|
|
43
|
+
reopen: "重新打開", cancelCard: "取消這張卡", confirmMain: "確認主任務",
|
|
44
|
+
assignee: "負責人", nobody: "沒人負責", unassign: "(不指派)", other: "其他…", otherTitle: "指派給其他 instance", otherLabel: "instance 名稱",
|
|
45
|
+
change: "改…", project: "專案", priority: "優先度", size: "大小", review: "你確認", reviewBox: "做完要停在待確認,等我確認",
|
|
46
|
+
reviewOn: "做完會停在待確認,等你確認", reviewOff: "做完直接完成,不等你確認",
|
|
47
|
+
parent: "主任務", deps: "相依", none: "沒有", edit: "編輯", depsTitle: "{0} 的相依", depsLabel: "要先完成的卡號,用逗號或空白分開(留空=沒有相依)",
|
|
48
|
+
depWaiting: "還沒完成", depDone: "已完成", opened: "{0} 開的・{1}・最後更新 {2}",
|
|
49
|
+
blockedReason: "卡住原因", goal: "目標", goalHint: "為什麼做、做完是什麼樣子", scope: "範圍", scopeHint: "做:…/不做:…",
|
|
50
|
+
definition: "完成定義", acceptance: "驗收條件", context: "背景", contextHint: "檔案路徑、相關卡號、已經做的決定",
|
|
51
|
+
state: "目前狀態", stateHint: "已完成/下一步/注意(負責人會隨時覆寫)", result: "產出", resultHint: "改了哪些檔案、怎麼驗證",
|
|
52
|
+
empty: "(未填,點一下編輯)", clickToEdit: "點一下編輯", ctrlEnter: "Ctrl+Enter 儲存", save: "儲存", cancel: "取消",
|
|
53
|
+
checklistEmpty: "(還沒有,點一下新增)", editList: "編輯清單", onePerLine: "一行一項;改了文字的項目會變回沒勾", items: "{0}/{1} 項",
|
|
54
|
+
subs: "子任務({0})", quickAdd: "新增子任務:輸入標題後按 Enter(小事可以不拆)",
|
|
55
|
+
tabComments: "留言 {0}", tabLog: "進度紀錄 {0}", tabHistory: "變更歷史", noComments: "還沒有留言", noLog: "還沒有進度紀錄",
|
|
56
|
+
rejectTag: "退回", approveTag: "確認", commentTo: "留言給 {0}(Ctrl+Enter 送出)", commentHint: "留言(Ctrl+Enter 送出)", send: "送出",
|
|
57
|
+
evCreated: "開卡", evMoved: "{0}→{1}", evAuto: "(自動)", evAssigned: "負責人 {0}→{1}", evUpdated: "修改{0}", evCommented: "留言", evLogged: "紀錄:{0}",
|
|
58
|
+
fTitle: "標題", fGoal: "目標", fScope: "範圍", fContext: "背景", fState: "目前狀態", fResult: "產出", fBlockedReason: "卡住原因", fSize: "大小",
|
|
59
|
+
fPriority: "優先度", fNeedsReview: "需要確認", fDependsOn: "相依", fAcceptance: "驗收條件", fProjectId: "專案",
|
|
60
|
+
position: "位置", moveUp: "往上移", moveDown: "往下移",
|
|
61
|
+
dispatcher: "派工", dispatcherHint: "卡住要決定的事會先通知派工者", dispatcherMe: "我", dispatcherNone: "(沒有)",
|
|
62
|
+
dispatcherPerson: "{0}(人)", now: "目前", nowNone: "沒有正在進行的子任務", fDispatcher: "派工者",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const MOVES = ["open", "claimed", "blocked", "review", "done"];
|
|
67
|
+
const LIMIT = { title: 100, goal: 300, scope: 600, context: 1000, state: 500, result: 1000, blockedReason: 400, acceptanceItems: 10 };
|
|
68
|
+
|
|
69
|
+
/** One history line, without who and when. */
|
|
70
|
+
export function describeEvent(ev) {
|
|
71
|
+
const d = ev.data || {};
|
|
72
|
+
switch (ev.type) {
|
|
73
|
+
case "created": return t("taskcard.evCreated");
|
|
74
|
+
case "moved": return `${t("taskcard.evMoved", t(`tasks.${d.from}`), t(`tasks.${d.to}`))}${d.auto ? t("taskcard.evAuto") : ""}${d.reason ? `: ${d.reason}` : ""}`;
|
|
75
|
+
case "assigned": return t("taskcard.evAssigned", d.from || "—", d.to || "—");
|
|
76
|
+
case "updated": return t("taskcard.evUpdated", Object.keys(d.changes || {}).map((k) => fieldName(k)).join(", "));
|
|
77
|
+
case "commented": return t("taskcard.evCommented");
|
|
78
|
+
case "logged": return t("taskcard.evLogged", d.text || "");
|
|
79
|
+
default: return ev.type;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const fieldName = (k) => { const key = `taskcard.f${k[0].toUpperCase()}${k.slice(1)}`; const s = t(key); return s === key ? k : s; };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `writable`: false over the public link (the card is shown, nothing on it changes). `place`: up and down in its column,
|
|
86
|
+
* for touch and keyboard (null when it cannot move).
|
|
87
|
+
*/
|
|
88
|
+
export function CardDialog({ id, tick, projects, writable = true, place = null, onClose, onOpen, reloadBoard }) {
|
|
89
|
+
const { instances } = useStore(appStore);
|
|
90
|
+
const [detail, setDetail] = useState(null);
|
|
91
|
+
const [missing, setMissing] = useState(false);
|
|
92
|
+
const [tab, setTab] = useState("comments");
|
|
93
|
+
const seq = useRef(0);
|
|
94
|
+
|
|
95
|
+
async function load() {
|
|
96
|
+
const mine = ++seq.current;
|
|
97
|
+
try {
|
|
98
|
+
const d = await boardApi.card(id);
|
|
99
|
+
if (mine === seq.current) { setDetail(d); setMissing(false); }
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (mine !== seq.current) return;
|
|
102
|
+
if (err.status === 404 || err.status === 400) setMissing(true);
|
|
103
|
+
else toast(errorText(err), false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
useEffect(() => { void load(); }, [id, tick]);
|
|
107
|
+
|
|
108
|
+
/** A write and what follows: its notes, the card and the board read again. Resolves "ok", "conflict" or "error". */
|
|
109
|
+
async function run(fn) {
|
|
110
|
+
try {
|
|
111
|
+
const r = await fn();
|
|
112
|
+
for (const n of notesForPeople(r && r.notes)) toast(n);
|
|
113
|
+
await load();
|
|
114
|
+
reloadBoard();
|
|
115
|
+
return "ok";
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const conflict = err.status === 409;
|
|
118
|
+
toast(conflict ? t("taskcard.conflict", id) : errorText(err), false);
|
|
119
|
+
if (conflict) await load();
|
|
120
|
+
return conflict ? "conflict" : "error";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** A move from this dialog: one at a time (claimed before the first await), then the card and the board read again. */
|
|
124
|
+
const moving = useRef(false);
|
|
125
|
+
const [busy, setBusy] = useState(false);
|
|
126
|
+
async function moved(start) {
|
|
127
|
+
if (moving.current) return;
|
|
128
|
+
moving.current = true;
|
|
129
|
+
setBusy(true);
|
|
130
|
+
try { const r = await start(); if (r) { await load(); reloadBoard(); } }
|
|
131
|
+
finally { moving.current = false; setBusy(false); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (missing || !detail) {
|
|
135
|
+
return html`<${Dialog} title=${id} onClose=${onClose} wide><p class="muted">${missing ? t("taskcard.missing", id) : t("taskcard.loading")}</p></${Dialog}>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { card, parent, children } = detail;
|
|
139
|
+
const isMain = card.kind === "main";
|
|
140
|
+
const derived = followsSubs(card);
|
|
141
|
+
const finished = card.status === "done" || card.status === "cancelled";
|
|
142
|
+
/** `version` guards against overwriting a newer card; null skips it (text typed after a conflict was shown). */
|
|
143
|
+
const save = (patch, version = card.version) => run(() => boardApi.update(card.id, version === null ? patch : { ...patch, version }));
|
|
144
|
+
const names = [...new Set([...instances.map((i) => i.name), card.assignee].filter(Boolean))].sort();
|
|
145
|
+
|
|
146
|
+
async function chooseAssignee(value) {
|
|
147
|
+
if (value === "\u0000other") {
|
|
148
|
+
const name = await askText({ title: t("taskcard.otherTitle"), label: t("taskcard.otherLabel"), required: true });
|
|
149
|
+
if (name) await save({ assignee: name }, null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await save({ assignee: value === "\u0000none" ? null : value });
|
|
153
|
+
}
|
|
154
|
+
async function chooseDispatcher(value) {
|
|
155
|
+
await save({ dispatcher: value === "\u0000none" ? null : value === "\u0000me" ? { kind: "person" } : { kind: "instance", name: value } });
|
|
156
|
+
}
|
|
157
|
+
async function editDeps() {
|
|
158
|
+
const text = await askText({ title: t("taskcard.depsTitle", card.id), label: t("taskcard.depsLabel"), initial: card.dependsOn.join(", ") });
|
|
159
|
+
if (text !== null) await save({ dependsOn: text.split(/[\s,,、]+/).filter(Boolean) }, null);
|
|
160
|
+
}
|
|
161
|
+
function copyLink() {
|
|
162
|
+
try {
|
|
163
|
+
const u = new URL(globalThis.location.href);
|
|
164
|
+
u.searchParams.set("card", card.id);
|
|
165
|
+
void navigator.clipboard?.writeText(u.toString()).then(() => toast(t("taskcard.linkCopied")));
|
|
166
|
+
} catch { /* no clipboard */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return html`<${Dialog} title=${`${card.id} · ${t(`tasks.${card.kind}`)}`} onClose=${onClose} wide label=${`${card.id} ${card.title}`}>
|
|
170
|
+
<div class="tc">
|
|
171
|
+
<div class="tc-head">
|
|
172
|
+
${writable ? html`<${InlineTitle} value=${card.title} version=${card.version} onSave=${(title, base) => save({ title }, base)} />` : html`<h3 class="tc-title">${card.title}</h3>`}
|
|
173
|
+
<span class="muted tc-ver">${t("taskcard.version", card.version)}</span>
|
|
174
|
+
<button type="button" class="icon-btn" title=${t("taskcard.copyLink")} aria-label=${t("taskcard.copyLink")} onClick=${copyLink}><${Icon} name="copy" size=${16} /></button>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="tc-actions">
|
|
178
|
+
<${StatusPill} status=${card.status} />
|
|
179
|
+
${writable ? html`
|
|
180
|
+
${card.status === "review" ? html`
|
|
181
|
+
<button type="button" class="btn btn-primary btn-sm" disabled=${busy} onClick=${() => moved(() => moveCard(card, "done"))}><${Icon} name="check" size=${14} />${isMain ? t("taskcard.confirmMain") : t("tasks.confirm")}</button>
|
|
182
|
+
<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => moved(() => moveCard(card, "claimed"))}>${t("tasks.sendBack")}</button>
|
|
183
|
+
${wrapUpText(card) ? html`<span class="tc-hint wrapup">${t("tasks.wrapLine", wrapUpText(card))}</span>` : null}` : null}
|
|
184
|
+
${card.status === "blocked" ? html`<button type="button" class="btn btn-primary btn-sm" disabled=${busy} onClick=${() => moved(() => answerBlocked(card))}>${t("tasks.answer")}</button>` : null}
|
|
185
|
+
${!derived && !finished ? html`<select class="tb-select" value="" aria-label=${t("taskcard.moveLabel")} onChange=${(e) => { const to = e.target.value; e.target.value = ""; if (to) void moved(() => moveCard(card, to)); }} disabled=${busy}>
|
|
186
|
+
<option value="">${t("taskcard.moveTo")}</option>
|
|
187
|
+
${MOVES.filter((s) => s !== card.status).map((s) => html`<option key=${s} value=${s}>${t(`tasks.${s}`)}</option>`)}</select>` : null}
|
|
188
|
+
${derived && !finished && card.status !== "blocked" ? html`<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => moved(() => moveCard(card, "blocked"))}>${t("tasks.block")}</button>` : null}
|
|
189
|
+
${derived && !finished ? html`<span class="muted tc-hint">${t("taskcard.followsSubs")}</span>` : null}
|
|
190
|
+
<span class="tb-grow"></span>
|
|
191
|
+
${finished
|
|
192
|
+
? html`<button type="button" class="btn btn-ghost btn-sm" disabled=${busy} onClick=${() => moved(() => moveCard(card, card.status === "done" ? "claimed" : "open"))}><${Icon} name="restart" size=${14} />${t("taskcard.reopen")}</button>`
|
|
193
|
+
: html`<button type="button" class="btn btn-ghost btn-sm danger" disabled=${busy} onClick=${() => moved(() => moveCard(card, "cancelled"))}>${t("taskcard.cancelCard")}</button>`}` : null}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<dl class="tc-meta">
|
|
197
|
+
<dt>${t("taskcard.assignee")}</dt>
|
|
198
|
+
<dd><span class="tb-who">${card.assignee || t("taskcard.nobody")}</span>
|
|
199
|
+
${writable ? html`<select class="tb-select" value="\u0000pick" aria-label=${t("taskcard.assignee")} onChange=${(e) => { const v = e.target.value; e.target.value = "\u0000pick"; void chooseAssignee(v); }}>
|
|
200
|
+
<option value="\u0000pick" disabled hidden>${t("taskcard.change")}</option>
|
|
201
|
+
${card.assignee ? html`<option value="\u0000none">${t("taskcard.unassign")}</option>` : null}
|
|
202
|
+
${names.filter((n) => n !== card.assignee).map((n) => html`<option key=${n} value=${n}>${n}</option>`)}
|
|
203
|
+
<option value="\u0000other">${t("taskcard.other")}</option>
|
|
204
|
+
</select>` : null}</dd>
|
|
205
|
+
${isMain && children.length ? html`<dt>${t("taskcard.now")}</dt><dd>${(() => {
|
|
206
|
+
const now = currentWork(card, children);
|
|
207
|
+
if (!now) return html`<span class="muted">${card.status === "review" ? t("tasks.review") : t("taskcard.nowNone")}</span>`;
|
|
208
|
+
return now.cards.map((c) => html`<button key=${c.id} type="button" class="tc-now" onClick=${() => onOpen(c.id)} title=${c.title}>
|
|
209
|
+
<span class=${`tb-now-label st-${now.tone}`}>${t(now.label)}</span><span class="tb-code">${c.id}</span><span class="tc-child-title">${c.title}</span>
|
|
210
|
+
<span class="tb-who">${c.assignee || t("tasks.nobody")}</span></button>`);
|
|
211
|
+
})()}</dd>` : null}
|
|
212
|
+
<dt>${t("taskcard.dispatcher")}</dt>
|
|
213
|
+
<dd><span class="tb-who" title=${t("taskcard.dispatcherHint")}>${!card.dispatcher ? t("taskcard.dispatcherNone")
|
|
214
|
+
: card.dispatcher.kind === "person" ? t("taskcard.dispatcherPerson", card.dispatcher.name) : card.dispatcher.name}</span>
|
|
215
|
+
${writable ? html`<select class="tb-select" value="\u0000pick" aria-label=${t("taskcard.dispatcher")} title=${t("taskcard.dispatcherHint")} onChange=${(e) => { const v = e.target.value; e.target.value = "\u0000pick"; void chooseDispatcher(v); }}>
|
|
216
|
+
<option value="\u0000pick" disabled hidden>${t("taskcard.change")}</option>
|
|
217
|
+
<option value="\u0000me">${t("taskcard.dispatcherMe")}</option>
|
|
218
|
+
${card.dispatcher ? html`<option value="\u0000none">${t("taskcard.dispatcherNone")}</option>` : null}
|
|
219
|
+
${names.filter((n) => !(card.dispatcher && card.dispatcher.kind === "instance" && card.dispatcher.name === n)).map((n) => html`<option key=${n} value=${n}>${n}</option>`)}
|
|
220
|
+
</select>` : null}</dd>
|
|
221
|
+
${isMain ? html`<dt>${t("taskcard.project")}</dt>
|
|
222
|
+
<dd>${writable ? html`<select class="tb-select" value=${card.projectId === null ? "" : String(card.projectId)} aria-label=${t("taskcard.project")}
|
|
223
|
+
onChange=${(e) => void save({ projectId: e.target.value === "" ? null : Number(e.target.value) })}>
|
|
224
|
+
<option value="">${t("tasks.noProject")}</option>
|
|
225
|
+
${projects.filter((p) => !p.archived || p.id === card.projectId).map((p) => html`<option key=${p.id} value=${String(p.id)}>${p.name}</option>`)}
|
|
226
|
+
</select>` : html`<span class="tc-value">${card.projectId === null ? t("tasks.noProject") : (projects.find((p) => p.id === card.projectId) || {}).name || "—"}</span>`}</dd>` : null}
|
|
227
|
+
<dt>${t("taskcard.priority")}</dt>
|
|
228
|
+
<dd>${writable ? html`<select class="tb-select" value=${card.priority} aria-label=${t("taskcard.priority")} onChange=${(e) => void save({ priority: e.target.value })}>
|
|
229
|
+
${["urgent", "high", "normal", "low"].map((p) => html`<option key=${p} value=${p}>${t(`tasks.${p}`)}</option>`)}</select>`
|
|
230
|
+
: html`<span class="tc-value">${t(`tasks.${card.priority}`)}</span>`}</dd>
|
|
231
|
+
<dt>${t("taskcard.size")}</dt>
|
|
232
|
+
<dd>${writable ? html`<select class="tb-select" value=${card.size || ""} aria-label=${t("taskcard.size")} onChange=${(e) => void save({ size: e.target.value || null })}>
|
|
233
|
+
<option value="">${t("tasks.sizeNone")}</option>${["S", "M", "L"].map((s) => html`<option key=${s} value=${s}>${t(`tasks.size${s}`)}</option>`)}</select>`
|
|
234
|
+
: html`<span class="tc-value">${card.size ? t(`tasks.size${card.size}`) : t("tasks.sizeNone")}</span>`}</dd>
|
|
235
|
+
<dt>${t("taskcard.review")}</dt>
|
|
236
|
+
<dd>${writable ? html`<label class="tb-check"><input type="checkbox" checked=${card.needsReview} onChange=${(e) => void save({ needsReview: e.target.checked })} />${t("taskcard.reviewBox")}</label>`
|
|
237
|
+
// #2299: read-only (the public link): the values as text, not controls that look usable and are not.
|
|
238
|
+
: html`<span class="tc-value">${t(card.needsReview ? "taskcard.reviewOn" : "taskcard.reviewOff")}</span>`}</dd>
|
|
239
|
+
${parent ? html`<dt>${t("taskcard.parent")}</dt><dd><button type="button" class="tb-link" onClick=${() => onOpen(parent.id)}><span class="tb-code">${parent.id}</span> ${parent.title}</button></dd>` : null}
|
|
240
|
+
<dt>${t("taskcard.deps")}</dt>
|
|
241
|
+
<dd>${card.dependsOn.length ? card.dependsOn.map((d) => html`<button key=${d} type="button" class=${`tc-dep${card.unmetDeps.includes(d) ? " waiting" : ""}`}
|
|
242
|
+
title=${card.unmetDeps.includes(d) ? t("taskcard.depWaiting") : t("taskcard.depDone")} onClick=${() => onOpen(d)}>${d}</button>`) : html`<span class="muted">${t("taskcard.none")}</span>`}
|
|
243
|
+
${writable ? html`<button type="button" class="btn btn-ghost btn-sm" onClick=${editDeps}>${t("taskcard.edit")}</button>` : null}</dd>
|
|
244
|
+
${place && (place.up || place.down) ? html`<dt>${t("taskcard.position")}</dt>
|
|
245
|
+
<dd><button type="button" class="btn btn-ghost btn-sm" disabled=${!place.up} aria-label=${t("taskcard.moveUp")} title=${t("taskcard.moveUp")} onClick=${place.up}><${Icon} name="up" size=${16} /></button>
|
|
246
|
+
<button type="button" class="btn btn-ghost btn-sm" disabled=${!place.down} aria-label=${t("taskcard.moveDown")} title=${t("taskcard.moveDown")} onClick=${place.down}><${Icon} name="down" size=${16} /></button></dd>` : null}
|
|
247
|
+
<dt></dt>
|
|
248
|
+
<dd class="muted">${t("taskcard.opened", card.createdBy, relTime(card.createdAt), relTime(card.updatedAt))}</dd>
|
|
249
|
+
</dl>
|
|
250
|
+
|
|
251
|
+
${card.status === "blocked" ? html`<${Field} title=${t("taskcard.blockedReason")} tone="blocked">
|
|
252
|
+
<${EditableText} readOnly=${!writable} value=${card.blockedReason} limit=${LIMIT.blockedReason} version=${card.version} onSave=${(v, base) => save({ blockedReason: v }, base)} /></${Field}>` : null}
|
|
253
|
+
<${Field} title=${t("taskcard.goal")}><${EditableText} readOnly=${!writable} value=${card.goal} limit=${LIMIT.goal} version=${card.version} placeholder=${t("taskcard.goalHint")} onSave=${(v, base) => save({ goal: v }, base)} /></${Field}>
|
|
254
|
+
${isMain ? html`<${Field} title=${t("taskcard.scope")}><${EditableText} readOnly=${!writable} value=${card.scope} limit=${LIMIT.scope} version=${card.version} placeholder=${t("taskcard.scopeHint")} onSave=${(v, base) => save({ scope: v }, base)} /></${Field}>` : null}
|
|
255
|
+
<${Field} title=${isMain ? t("taskcard.definition") : t("taskcard.acceptance")}>
|
|
256
|
+
<${Checklist} card=${card} readOnly=${!writable} onToggle=${(n, done) => save(done ? { check: [n] } : { uncheck: [n] })} onReplace=${(items, base) => save({ acceptance: items }, base)} /></${Field}>
|
|
257
|
+
<${Field} title=${t("taskcard.context")}><${EditableText} readOnly=${!writable} value=${card.context} limit=${LIMIT.context} version=${card.version} placeholder=${t("taskcard.contextHint")} onSave=${(v, base) => save({ context: v }, base)} /></${Field}>
|
|
258
|
+
<${Field} title=${t("taskcard.state")}><${EditableText} readOnly=${!writable} value=${card.state} limit=${LIMIT.state} version=${card.version} placeholder=${t("taskcard.stateHint")} onSave=${(v, base) => save({ state: v }, base)} /></${Field}>
|
|
259
|
+
<${Field} title=${t("taskcard.result")}><${EditableText} readOnly=${!writable} value=${card.result} limit=${LIMIT.result} version=${card.version} placeholder=${t("taskcard.resultHint")} onSave=${(v, base) => save({ result: v }, base)} /></${Field}>
|
|
260
|
+
|
|
261
|
+
${isMain ? html`<${Field} title=${t("taskcard.subs", children.length)}>
|
|
262
|
+
${children.length ? html`<ul class="tc-children">${children.map((c) => html`<li key=${c.id}><button type="button" onClick=${() => onOpen(c.id)}>
|
|
263
|
+
<${StatusPill} status=${c.status} /><span class="tb-code">${c.id}</span><span class="tc-child-title">${c.title}</span><span class="tb-who">${c.assignee || ""}</span></button></li>`)}</ul>` : null}
|
|
264
|
+
${finished || !writable ? null : html`<${QuickAdd} placeholder=${t("taskcard.quickAdd")} onAdd=${(title) => run(() => boardApi.create({ parent: card.id, title }))} />`}
|
|
265
|
+
</${Field}>` : null}
|
|
266
|
+
|
|
267
|
+
<div class="seg tc-tabs" role="tablist">
|
|
268
|
+
${[["comments", t("taskcard.tabComments", detail.comments.length)], ["log", t("taskcard.tabLog", detail.logs.length)], ["history", t("taskcard.tabHistory")]].map(([k, label]) =>
|
|
269
|
+
html`<button key=${k} type="button" role="tab" aria-selected=${tab === k ? "true" : "false"} class=${`seg-item${tab === k ? " active" : ""}`} onClick=${() => setTab(k)}>${label}</button>`)}
|
|
270
|
+
</div>
|
|
271
|
+
${tab === "comments" ? html`<${Comments} entries=${detail.comments} card=${card} writable=${writable} onSend=${(text) => run(() => boardApi.update(card.id, { comment: text }))} />` : null}
|
|
272
|
+
${tab === "log" ? html`<ul class="tc-feed">${detail.logs.length ? detail.logs.map((e) => html`<li key=${e.id}><span class="muted">${relTime(e.at)}</span> <b>${e.author}</b> ${e.text}</li>`)
|
|
273
|
+
: html`<li class="muted">${t("taskcard.noLog")}</li>`}</ul>` : null}
|
|
274
|
+
${tab === "history" ? html`<ul class="tc-feed">${[...detail.history].reverse().map((ev) => html`<li key=${ev.id}><span class="muted">${relTime(ev.at)}</span> <b>${ev.actor}</b> ${describeEvent(ev)}</li>`)}</ul>` : null}
|
|
275
|
+
</div>
|
|
276
|
+
</${Dialog}>`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function Field({ title, tone, children }) {
|
|
280
|
+
return html`<section class=${`tc-field${tone ? ` tone-${tone}` : ""}`}><h3>${title}</h3>${children}</section>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Which version an edit started from: a save sends it, so an agent's newer write is caught (409). After a conflict
|
|
285
|
+
* the next save of the same draft overwrites on purpose (null: no check).
|
|
286
|
+
*/
|
|
287
|
+
function useEditBase(version) {
|
|
288
|
+
const [base, setBase] = useState(version);
|
|
289
|
+
return {
|
|
290
|
+
start: () => setBase(version),
|
|
291
|
+
current: () => (base === undefined ? version : base),
|
|
292
|
+
afterSave: (outcome) => { if (outcome === "conflict") setBase(null); },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Focus moves into an edit box as it opens (before the next key: Esc must reach the box, not the dialog). */
|
|
297
|
+
function useAutoFocus(open) {
|
|
298
|
+
const ref = useRef(null);
|
|
299
|
+
useLayoutEffect(() => { if (open && ref.current) ref.current.focus(); }, [open]);
|
|
300
|
+
return ref;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Esc leaves the edit, not the dialog. */
|
|
304
|
+
function escapeEdit(e, cancel) {
|
|
305
|
+
if (e.key !== "Escape") return false;
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
cancel();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function InlineTitle({ value, version, onSave }) {
|
|
313
|
+
const [draft, setDraft] = useState(null);
|
|
314
|
+
const base = useEditBase(version);
|
|
315
|
+
const ref = useAutoFocus(draft !== null);
|
|
316
|
+
if (draft === null) {
|
|
317
|
+
return html`<button type="button" class="tc-title" title=${t("taskcard.clickToEdit")} onClick=${() => { setDraft(value); base.start(); }}>${value}</button>`;
|
|
318
|
+
}
|
|
319
|
+
const commit = async () => {
|
|
320
|
+
const v = draft.trim();
|
|
321
|
+
if (!v || v === value) { setDraft(null); return; }
|
|
322
|
+
const outcome = await onSave(v, base.current());
|
|
323
|
+
base.afterSave(outcome);
|
|
324
|
+
if (outcome === "ok") setDraft(null);
|
|
325
|
+
};
|
|
326
|
+
return html`<input class="tc-title-input" value=${draft} maxlength=${LIMIT.title} ref=${ref}
|
|
327
|
+
onInput=${(e) => setDraft(e.target.value)} onBlur=${() => void commit()}
|
|
328
|
+
onKeyDown=${(e) => { if (escapeEdit(e, () => setDraft(null))) return; if (e.key === "Enter") { e.preventDefault(); void commit(); } }} />`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function EditableText({ value, limit, placeholder, version, readOnly = false, onSave }) {
|
|
332
|
+
const [draft, setDraft] = useState(null);
|
|
333
|
+
const [saving, setSaving] = useState(false);
|
|
334
|
+
const base = useEditBase(version);
|
|
335
|
+
const ref = useAutoFocus(draft !== null);
|
|
336
|
+
if (readOnly) return html`<p class=${`tc-text${value ? "" : " empty"}`}>${value || "—"}</p>`;
|
|
337
|
+
if (draft === null) {
|
|
338
|
+
return html`<button type="button" class=${`tc-text${value ? "" : " empty"}`} title=${t("taskcard.clickToEdit")} onClick=${() => { setDraft(value); base.start(); }}>${value || placeholder || t("taskcard.empty")}</button>`;
|
|
339
|
+
}
|
|
340
|
+
const length = [...draft].length;
|
|
341
|
+
const submit = async () => {
|
|
342
|
+
if (saving) return;
|
|
343
|
+
setSaving(true);
|
|
344
|
+
const outcome = await onSave(draft.trim(), base.current());
|
|
345
|
+
setSaving(false);
|
|
346
|
+
base.afterSave(outcome);
|
|
347
|
+
if (outcome === "ok") setDraft(null);
|
|
348
|
+
};
|
|
349
|
+
return html`<div class="tc-edit">
|
|
350
|
+
<textarea rows=${Math.min(10, Math.max(3, draft.split("\n").length + 1))} value=${draft} ref=${ref}
|
|
351
|
+
onInput=${(e) => setDraft(e.target.value)}
|
|
352
|
+
onKeyDown=${(e) => { if (escapeEdit(e, () => setDraft(null))) return; if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); } }}></textarea>
|
|
353
|
+
<div class="tc-edit-bar">
|
|
354
|
+
<span class=${`tc-counter${length > limit ? " over" : ""}`}>${length}/${limit}</span>
|
|
355
|
+
<span class="muted">${t("taskcard.ctrlEnter")}</span>
|
|
356
|
+
<span class="tb-grow"></span>
|
|
357
|
+
<button type="button" class="btn btn-sm" onClick=${() => setDraft(null)}>${t("taskcard.cancel")}</button>
|
|
358
|
+
<button type="button" class="btn btn-primary btn-sm" disabled=${saving || length > limit} onClick=${submit}>${t("taskcard.save")}</button>
|
|
359
|
+
</div></div>`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function Checklist({ card, readOnly = false, onToggle, onReplace }) {
|
|
363
|
+
const [draft, setDraft] = useState(null);
|
|
364
|
+
// One tick at a time: each sends the version it saw, so a second tick before the first is saved would be refused.
|
|
365
|
+
// The tick being saved shows as asked meanwhile (the box does not flick back until the card is read again).
|
|
366
|
+
const [ticking, setTicking] = useState(null);
|
|
367
|
+
const tick = async (n, done) => { setTicking({ n, done }); try { await onToggle(n, done); } finally { setTicking(null); } };
|
|
368
|
+
const base = useEditBase(card.version);
|
|
369
|
+
const ref = useAutoFocus(draft !== null);
|
|
370
|
+
const start = (text) => { setDraft(text); base.start(); };
|
|
371
|
+
if (draft !== null) {
|
|
372
|
+
const items = draft.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
373
|
+
const save = async () => {
|
|
374
|
+
const outcome = await onReplace(items, base.current());
|
|
375
|
+
base.afterSave(outcome);
|
|
376
|
+
if (outcome === "ok") setDraft(null);
|
|
377
|
+
};
|
|
378
|
+
return html`<div class="tc-edit">
|
|
379
|
+
<textarea rows=${Math.max(3, items.length + 1)} value=${draft} ref=${ref}
|
|
380
|
+
onInput=${(e) => setDraft(e.target.value)}
|
|
381
|
+
onKeyDown=${(e) => { if (escapeEdit(e, () => setDraft(null))) return; if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void save(); } }}></textarea>
|
|
382
|
+
<div class="tc-edit-bar">
|
|
383
|
+
<span class=${`tc-counter${items.length > LIMIT.acceptanceItems ? " over" : ""}`}>${t("taskcard.items", items.length, LIMIT.acceptanceItems)}</span>
|
|
384
|
+
<span class="muted">${t("taskcard.onePerLine")}</span>
|
|
385
|
+
<span class="tb-grow"></span>
|
|
386
|
+
<button type="button" class="btn btn-sm" onClick=${() => setDraft(null)}>${t("taskcard.cancel")}</button>
|
|
387
|
+
<button type="button" class="btn btn-primary btn-sm" disabled=${items.length > LIMIT.acceptanceItems} onClick=${save}>${t("taskcard.save")}</button>
|
|
388
|
+
</div></div>`;
|
|
389
|
+
}
|
|
390
|
+
if (!card.acceptance.length) return readOnly ? html`<p class="tc-text empty">—</p>` : html`<button type="button" class="tc-text empty" onClick=${() => start("")}>${t("taskcard.checklistEmpty")}</button>`;
|
|
391
|
+
return html`<ul class="tc-checklist">${card.acceptance.map((a, i) => html`<li key=${`${i}-${a.text}`}><label class="tb-check">
|
|
392
|
+
<input type="checkbox" checked=${ticking && ticking.n === i + 1 ? ticking.done : a.done} disabled=${readOnly || !!ticking} onChange=${() => void tick(i + 1, !a.done)} /><span class=${a.done ? "done" : ""}>${a.text}</span></label></li>`)}</ul>
|
|
393
|
+
${readOnly ? null : html`<button type="button" class="btn btn-ghost btn-sm" onClick=${() => start(card.acceptance.map((a) => a.text).join("\n"))}>${t("taskcard.editList")}</button>`}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function QuickAdd({ placeholder, onAdd }) {
|
|
397
|
+
const [title, setTitle] = useState("");
|
|
398
|
+
const busy = useRef(false);
|
|
399
|
+
return html`<input class="tc-quick-add" value=${title} placeholder=${placeholder} aria-label=${placeholder} maxlength=${LIMIT.title}
|
|
400
|
+
onInput=${(e) => setTitle(e.target.value)}
|
|
401
|
+
onKeyDown=${async (e) => {
|
|
402
|
+
if (e.key !== "Enter" || !title.trim() || busy.current) return;
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
busy.current = true;
|
|
405
|
+
if ((await onAdd(title.trim())) === "ok") setTitle("");
|
|
406
|
+
busy.current = false;
|
|
407
|
+
}} />`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Comments({ entries, card, writable = true, onSend }) {
|
|
411
|
+
const [text, setText] = useState("");
|
|
412
|
+
const [sending, setSending] = useState(false);
|
|
413
|
+
const busy = useRef(false);
|
|
414
|
+
const send = async () => {
|
|
415
|
+
if (!text.trim() || busy.current) return;
|
|
416
|
+
busy.current = true;
|
|
417
|
+
setSending(true);
|
|
418
|
+
if ((await onSend(text.trim())) === "ok") setText("");
|
|
419
|
+
setSending(false);
|
|
420
|
+
busy.current = false;
|
|
421
|
+
};
|
|
422
|
+
return html`<div class="tc-comments">
|
|
423
|
+
${entries.length ? null : html`<p class="muted">${t("taskcard.noComments")}</p>`}
|
|
424
|
+
${entries.map((e) => html`<div key=${e.id} class=${`tc-comment${e.authorKind === "person" ? " person" : ""}`}>
|
|
425
|
+
<div class="tc-comment-head"><b>${e.author}</b>
|
|
426
|
+
${e.kind === "reject" ? html`<span class="tb-tag st-blocked">${t("taskcard.rejectTag")}</span>` : null}
|
|
427
|
+
${e.kind === "approve" ? html`<span class="tb-tag st-done">${t("taskcard.approveTag")}</span>` : null}
|
|
428
|
+
<span class="muted">${relTime(e.at)}</span></div>
|
|
429
|
+
<div class="tc-comment-body">${e.text}</div></div>`)}
|
|
430
|
+
${writable ? html`<div class="tc-composer">
|
|
431
|
+
<textarea rows="2" value=${text} placeholder=${card.assignee ? t("taskcard.commentTo", card.assignee) : t("taskcard.commentHint")} aria-label=${t("taskcard.commentHint")}
|
|
432
|
+
onInput=${(e) => setText(e.target.value)}
|
|
433
|
+
onKeyDown=${(e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); e.currentTarget.blur(); return; } if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void send(); } }}></textarea>
|
|
434
|
+
<button type="button" class="btn btn-primary btn-sm" disabled=${sending || !text.trim()} onClick=${send}>${t("taskcard.send")}</button>
|
|
435
|
+
</div>` : null}</div>`;
|
|
436
|
+
}
|