bosun 0.41.2 → 0.41.4
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 +1 -1
- package/agent/agent-pool.mjs +9 -2
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +119 -6
- package/agent/autofix-git.mjs +33 -0
- package/agent/autofix-prompts.mjs +151 -0
- package/agent/autofix.mjs +11 -175
- package/agent/bosun-skills.mjs +3 -2
- package/bosun.config.example.json +17 -0
- package/bosun.schema.json +87 -188
- package/cli.mjs +34 -1
- package/config/config-doctor.mjs +5 -250
- package/config/config-file-names.mjs +5 -0
- package/config/config.mjs +89 -493
- package/config/executor-config.mjs +493 -0
- package/config/repo-root.mjs +1 -2
- package/config/workspace-health.mjs +242 -0
- package/git/git-safety.mjs +15 -0
- package/github/github-oauth-portal.mjs +46 -0
- package/infra/library-manager-utils.mjs +22 -0
- package/infra/library-manager-well-known-sources.mjs +578 -0
- package/infra/library-manager.mjs +512 -1030
- package/infra/monitor.mjs +35 -9
- package/infra/session-tracker.mjs +10 -7
- package/kanban/kanban-adapter.mjs +17 -1
- package/lib/codebase-audit-manifests.mjs +117 -0
- package/lib/codebase-audit.mjs +18 -115
- package/package.json +18 -3
- package/server/setup-web-server.mjs +58 -5
- package/server/ui-server.mjs +1394 -79
- package/shell/codex-config-file.mjs +178 -0
- package/shell/codex-config.mjs +538 -575
- package/task/task-cli.mjs +54 -3
- package/task/task-executor.mjs +143 -13
- package/task/task-store.mjs +409 -1
- package/telegram/telegram-bot.mjs +127 -0
- package/tools/apply-pr-suggestions.mjs +401 -0
- package/tools/syntax-check.mjs +28 -9
- package/ui/app.js +3 -14
- package/ui/components/kanban-board.js +227 -4
- package/ui/components/session-list.js +85 -5
- package/ui/demo-defaults.js +338 -84
- package/ui/demo.html +155 -0
- package/ui/modules/session-api.js +96 -0
- package/ui/modules/settings-schema.js +1 -2
- package/ui/modules/state.js +43 -3
- package/ui/setup.html +4 -5
- package/ui/styles/components.css +58 -4
- package/ui/tabs/agents.js +12 -15
- package/ui/tabs/control.js +1 -0
- package/ui/tabs/library.js +484 -22
- package/ui/tabs/manual-flows.js +105 -29
- package/ui/tabs/tasks.js +848 -141
- package/ui/tabs/telemetry.js +129 -11
- package/ui/tabs/workflow-canvas-utils.mjs +130 -0
- package/ui/tabs/workflows.js +293 -23
- package/voice/voice-tool-definitions.mjs +757 -0
- package/voice/voice-tools.mjs +34 -778
- package/workflow/manual-flow-audit.mjs +165 -0
- package/workflow/manual-flows.mjs +164 -259
- package/workflow/workflow-engine.mjs +147 -58
- package/workflow/workflow-nodes/definitions.mjs +1207 -0
- package/workflow/workflow-nodes/transforms.mjs +612 -0
- package/workflow/workflow-nodes.mjs +358 -63
- package/workflow/workflow-templates.mjs +313 -191
- package/workflow-templates/_helpers.mjs +154 -0
- package/workflow-templates/agents.mjs +61 -4
- package/workflow-templates/code-quality.mjs +7 -7
- package/workflow-templates/github.mjs +20 -10
- package/workflow-templates/task-batch.mjs +44 -11
- package/workflow-templates/task-lifecycle.mjs +31 -6
- package/workspace/worktree-manager.mjs +277 -3
package/ui/tabs/tasks.js
CHANGED
|
@@ -39,11 +39,13 @@ import {
|
|
|
39
39
|
setPendingChange,
|
|
40
40
|
clearPendingChange,
|
|
41
41
|
sanitizeTaskText,
|
|
42
|
+
isPlaceholderTaskDescription,
|
|
42
43
|
} from "../modules/state.js";
|
|
43
44
|
import { ICONS } from "../modules/icons.js";
|
|
44
45
|
import {
|
|
45
46
|
cloneValue,
|
|
46
47
|
formatRelative,
|
|
48
|
+
formatDuration,
|
|
47
49
|
truncate,
|
|
48
50
|
formatBytes,
|
|
49
51
|
debounce,
|
|
@@ -51,6 +53,12 @@ import {
|
|
|
51
53
|
exportAsJSON,
|
|
52
54
|
countChangedFields,
|
|
53
55
|
} from "../modules/utils.js";
|
|
56
|
+
import { navigateTo } from "../modules/router.js";
|
|
57
|
+
import {
|
|
58
|
+
loadSessions,
|
|
59
|
+
loadSessionMessages,
|
|
60
|
+
selectedSessionId,
|
|
61
|
+
} from "../components/session-list.js";
|
|
54
62
|
import {
|
|
55
63
|
Modal,
|
|
56
64
|
SaveDiscardBar,
|
|
@@ -65,6 +73,7 @@ import {
|
|
|
65
73
|
} from "../components/forms.js";
|
|
66
74
|
import { KanbanBoard } from "../components/kanban-board.js";
|
|
67
75
|
import { VoiceMicButton, VoiceMicButtonInline } from "../modules/voice.js";
|
|
76
|
+
import { openWorkflowRunsView } from "./workflows.js";
|
|
68
77
|
import {
|
|
69
78
|
workspaces as managedWorkspaces,
|
|
70
79
|
activeWorkspaceId,
|
|
@@ -139,7 +148,7 @@ const STATUS_CHIPS = [
|
|
|
139
148
|
{ value: "inprogress", label: "Active" },
|
|
140
149
|
{ value: "inreview", label: "Review" },
|
|
141
150
|
{ value: "done", label: "Done" },
|
|
142
|
-
{ value: "
|
|
151
|
+
{ value: "blocked", label: "Blocked" },
|
|
143
152
|
];
|
|
144
153
|
|
|
145
154
|
const PRIORITY_CHIPS = [
|
|
@@ -163,7 +172,7 @@ const SNAPSHOT_STATUS_MAP = {
|
|
|
163
172
|
Active: "inprogress",
|
|
164
173
|
Review: "inreview",
|
|
165
174
|
Done: "done",
|
|
166
|
-
|
|
175
|
+
Blocked: "blocked",
|
|
167
176
|
};
|
|
168
177
|
|
|
169
178
|
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, "": 4 };
|
|
@@ -975,9 +984,15 @@ async function fetchFirstAvailableDagPath(paths = []) {
|
|
|
975
984
|
return null;
|
|
976
985
|
}
|
|
977
986
|
|
|
978
|
-
function buildTaskDescriptionFallback(rawTitle, rawDescription) {
|
|
987
|
+
export function buildTaskDescriptionFallback(rawTitle, rawDescription) {
|
|
979
988
|
const title = sanitizeTaskText(rawTitle || "");
|
|
980
989
|
const description = sanitizeTaskText(rawDescription || "");
|
|
990
|
+
if (isPlaceholderTaskDescription(description)) {
|
|
991
|
+
if (!title) {
|
|
992
|
+
return "No description provided yet. Add scope, key files, and acceptance checks before dispatch.";
|
|
993
|
+
}
|
|
994
|
+
return `Implementation notes for "${title}". Include scope, key files, risks, and acceptance checks before dispatch.`;
|
|
995
|
+
}
|
|
981
996
|
if (description) return description;
|
|
982
997
|
if (!title) {
|
|
983
998
|
return "No description provided yet. Add scope, key files, and acceptance checks before dispatch.";
|
|
@@ -985,6 +1000,13 @@ function buildTaskDescriptionFallback(rawTitle, rawDescription) {
|
|
|
985
1000
|
return `Implementation notes for "${title}". Include scope, key files, risks, and acceptance checks before dispatch.`;
|
|
986
1001
|
}
|
|
987
1002
|
|
|
1003
|
+
function buildTaskDetailPath(taskId, options = {}) {
|
|
1004
|
+
const params = new URLSearchParams({ taskId: String(taskId || "") });
|
|
1005
|
+
if (options.includeDag === false) params.set("includeDag", "0");
|
|
1006
|
+
if (options.includeWorkflowRuns === false) params.set("includeWorkflowRuns", "0");
|
|
1007
|
+
return `/api/tasks/detail?${params.toString()}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
988
1010
|
|
|
989
1011
|
function getTaskCollectionValues(task, keys = []) {
|
|
990
1012
|
const out = [];
|
|
@@ -1063,6 +1085,89 @@ function buildTaskHistoryEntries(task) {
|
|
|
1063
1085
|
.slice(0, 40);
|
|
1064
1086
|
}
|
|
1065
1087
|
|
|
1088
|
+
function pickTaskWorkflowSessionId(entry) {
|
|
1089
|
+
if (!entry || typeof entry !== "object") return "";
|
|
1090
|
+
for (const value of [
|
|
1091
|
+
entry.sessionId,
|
|
1092
|
+
entry.primarySessionId,
|
|
1093
|
+
entry.threadId,
|
|
1094
|
+
entry.agentSessionId,
|
|
1095
|
+
entry.meta?.sessionId,
|
|
1096
|
+
entry.meta?.threadId,
|
|
1097
|
+
]) {
|
|
1098
|
+
const normalized = String(value || "").trim();
|
|
1099
|
+
if (normalized) return normalized;
|
|
1100
|
+
}
|
|
1101
|
+
return "";
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function normalizeTaskWorkflowRunEntry(entry) {
|
|
1105
|
+
if (entry == null) return null;
|
|
1106
|
+
if (typeof entry === "string") {
|
|
1107
|
+
const workflowId = String(entry || "").trim();
|
|
1108
|
+
return workflowId
|
|
1109
|
+
? {
|
|
1110
|
+
workflowId,
|
|
1111
|
+
workflowName: "",
|
|
1112
|
+
workflowLabel: workflowId,
|
|
1113
|
+
runId: "",
|
|
1114
|
+
status: "",
|
|
1115
|
+
outcome: "",
|
|
1116
|
+
result: "",
|
|
1117
|
+
summary: "",
|
|
1118
|
+
timestamp: null,
|
|
1119
|
+
startedAt: null,
|
|
1120
|
+
endedAt: null,
|
|
1121
|
+
duration: null,
|
|
1122
|
+
sessionId: "",
|
|
1123
|
+
primarySessionId: "",
|
|
1124
|
+
hasRunLink: false,
|
|
1125
|
+
hasSessionLink: false,
|
|
1126
|
+
url: "",
|
|
1127
|
+
nodeId: "",
|
|
1128
|
+
meta: {},
|
|
1129
|
+
}
|
|
1130
|
+
: null;
|
|
1131
|
+
}
|
|
1132
|
+
const workflowId = String(entry.workflowId || entry.id || entry.templateId || "").trim();
|
|
1133
|
+
const workflowName = String(entry.workflowName || entry.name || "").trim();
|
|
1134
|
+
const runId = String(entry.runId || entry.executionId || entry.attemptId || "").trim();
|
|
1135
|
+
const status = String(entry.status || "").trim();
|
|
1136
|
+
const outcome = String(entry.outcome || "").trim();
|
|
1137
|
+
const summary = String(entry.summary || entry.message || entry.reason || "").trim();
|
|
1138
|
+
const result = summary || String(entry.result || "").trim();
|
|
1139
|
+
const startedAt = entry.startedAt || entry.createdAt || null;
|
|
1140
|
+
const endedAt = entry.endedAt || entry.completedAt || entry.timestamp || null;
|
|
1141
|
+
const timestamp = endedAt || startedAt || null;
|
|
1142
|
+
const duration = Number.isFinite(Number(entry.duration))
|
|
1143
|
+
? Number(entry.duration)
|
|
1144
|
+
: (startedAt && endedAt
|
|
1145
|
+
? Math.max(0, new Date(endedAt).getTime() - new Date(startedAt).getTime())
|
|
1146
|
+
: null);
|
|
1147
|
+
const sessionId = pickTaskWorkflowSessionId(entry);
|
|
1148
|
+
return {
|
|
1149
|
+
workflowId,
|
|
1150
|
+
workflowName,
|
|
1151
|
+
workflowLabel: workflowName || workflowId || "workflow",
|
|
1152
|
+
runId,
|
|
1153
|
+
status,
|
|
1154
|
+
outcome,
|
|
1155
|
+
result,
|
|
1156
|
+
summary,
|
|
1157
|
+
timestamp,
|
|
1158
|
+
startedAt,
|
|
1159
|
+
endedAt,
|
|
1160
|
+
duration,
|
|
1161
|
+
sessionId,
|
|
1162
|
+
primarySessionId: String(entry.primarySessionId || sessionId).trim(),
|
|
1163
|
+
hasRunLink: Boolean(runId),
|
|
1164
|
+
hasSessionLink: Boolean(sessionId),
|
|
1165
|
+
url: String(entry.url || "").trim(),
|
|
1166
|
+
nodeId: String(entry.nodeId || "").trim(),
|
|
1167
|
+
meta: entry.meta && typeof entry.meta === "object" ? { ...entry.meta } : {},
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1066
1171
|
function buildTaskWorkflowRuns(task) {
|
|
1067
1172
|
const rows = getTaskCollectionValues(task, [
|
|
1068
1173
|
"workflowRuns",
|
|
@@ -1070,19 +1175,7 @@ function buildTaskWorkflowRuns(task) {
|
|
|
1070
1175
|
"workflows",
|
|
1071
1176
|
]);
|
|
1072
1177
|
return rows
|
|
1073
|
-
.map((entry) =>
|
|
1074
|
-
if (entry == null) return null;
|
|
1075
|
-
if (typeof entry === "string") {
|
|
1076
|
-
return { workflowId: entry, runId: "", status: "", result: "", timestamp: null };
|
|
1077
|
-
}
|
|
1078
|
-
return {
|
|
1079
|
-
workflowId: String(entry.workflowId || entry.id || entry.templateId || "").trim(),
|
|
1080
|
-
runId: String(entry.runId || entry.executionId || entry.attemptId || "").trim(),
|
|
1081
|
-
status: String(entry.status || entry.outcome || entry.result || "").trim(),
|
|
1082
|
-
result: String(entry.summary || entry.message || entry.reason || "").trim(),
|
|
1083
|
-
timestamp: entry.timestamp || entry.completedAt || entry.createdAt || null,
|
|
1084
|
-
};
|
|
1085
|
-
})
|
|
1178
|
+
.map((entry) => normalizeTaskWorkflowRunEntry(entry))
|
|
1086
1179
|
.filter((entry) => entry && (entry.workflowId || entry.runId || entry.status || entry.result))
|
|
1087
1180
|
.sort((a, b) => {
|
|
1088
1181
|
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
@@ -1092,6 +1185,56 @@ function buildTaskWorkflowRuns(task) {
|
|
|
1092
1185
|
.slice(0, 30);
|
|
1093
1186
|
}
|
|
1094
1187
|
|
|
1188
|
+
export function buildTaskWorkflowRunMetaLine(run) {
|
|
1189
|
+
const parts = [];
|
|
1190
|
+
const label = String(run?.workflowLabel || run?.workflowName || run?.workflowId || "workflow").trim();
|
|
1191
|
+
if (label) parts.push(label);
|
|
1192
|
+
if (run?.runId) parts.push(`run ${run.runId}`);
|
|
1193
|
+
if (run?.timestamp) parts.push(formatRelative(run.timestamp));
|
|
1194
|
+
if (Number.isFinite(Number(run?.duration)) && Number(run.duration) > 0) {
|
|
1195
|
+
parts.push(formatDuration(Number(run.duration)));
|
|
1196
|
+
}
|
|
1197
|
+
return parts.join(" · ");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
export function buildTaskWorkflowRunStatusLine(run) {
|
|
1201
|
+
const parts = [];
|
|
1202
|
+
const status = String(run?.status || "").trim();
|
|
1203
|
+
const outcome = String(run?.outcome || "").trim();
|
|
1204
|
+
const summary = String(run?.summary || run?.result || "").trim();
|
|
1205
|
+
if (status) parts.push(status);
|
|
1206
|
+
if (outcome && outcome !== status) parts.push(outcome);
|
|
1207
|
+
if (summary && summary !== status && summary !== outcome) parts.push(summary);
|
|
1208
|
+
return parts.join(" · ") || "No status summary";
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export async function openTaskWorkflowRun(run, deps = {}) {
|
|
1212
|
+
const navigate = deps.navigateTo || navigateTo;
|
|
1213
|
+
const openRuns = deps.openWorkflowRunsView || openWorkflowRunsView;
|
|
1214
|
+
const workflowId = String(run?.workflowId || "").trim();
|
|
1215
|
+
const runId = String(run?.runId || "").trim();
|
|
1216
|
+
if (!runId) return false;
|
|
1217
|
+
const navigated = navigate("workflows");
|
|
1218
|
+
if (navigated === false) return false;
|
|
1219
|
+
openRuns(workflowId, runId);
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export async function openTaskWorkflowAgentHistory(run, deps = {}) {
|
|
1224
|
+
const navigate = deps.navigateTo || navigateTo;
|
|
1225
|
+
const loadAllSessions = deps.loadSessions || loadSessions;
|
|
1226
|
+
const loadMessages = deps.loadSessionMessages || loadSessionMessages;
|
|
1227
|
+
const selectedStore = deps.selectedSessionId || selectedSessionId;
|
|
1228
|
+
const sessionId = pickTaskWorkflowSessionId(run);
|
|
1229
|
+
if (!sessionId) return false;
|
|
1230
|
+
const navigated = navigate("agents");
|
|
1231
|
+
if (navigated === false) return false;
|
|
1232
|
+
await loadAllSessions({ type: "task", workspace: "all" });
|
|
1233
|
+
selectedStore.value = sessionId;
|
|
1234
|
+
await loadMessages(sessionId, { limit: 50 });
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1095
1238
|
function buildTaskRelatedLinks(task) {
|
|
1096
1239
|
const links = [];
|
|
1097
1240
|
const branch =
|
|
@@ -1603,6 +1746,28 @@ function TriggerTemplateCard({
|
|
|
1603
1746
|
`;
|
|
1604
1747
|
}
|
|
1605
1748
|
|
|
1749
|
+
function sanitizeTriggerTemplatePayload(template = {}) {
|
|
1750
|
+
if (!template || typeof template !== "object") {
|
|
1751
|
+
return {};
|
|
1752
|
+
}
|
|
1753
|
+
const payload = {};
|
|
1754
|
+
for (const key of [
|
|
1755
|
+
"id",
|
|
1756
|
+
"name",
|
|
1757
|
+
"description",
|
|
1758
|
+
"enabled",
|
|
1759
|
+
"action",
|
|
1760
|
+
"minIntervalMinutes",
|
|
1761
|
+
"trigger",
|
|
1762
|
+
"config",
|
|
1763
|
+
]) {
|
|
1764
|
+
if (Object.prototype.hasOwnProperty.call(template, key)) {
|
|
1765
|
+
payload[key] = template[key];
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return payload;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1606
1771
|
function TriggerTemplatesModal({ onClose }) {
|
|
1607
1772
|
const [loading, setLoading] = useState(true);
|
|
1608
1773
|
const [saving, setSaving] = useState(false);
|
|
@@ -1721,11 +1886,16 @@ function TriggerTemplatesModal({ onClose }) {
|
|
|
1721
1886
|
}, []);
|
|
1722
1887
|
|
|
1723
1888
|
const handleToggleTemplate = async (template, nextEnabled) => {
|
|
1724
|
-
await persistUpdate({
|
|
1889
|
+
await persistUpdate({
|
|
1890
|
+
template: {
|
|
1891
|
+
...sanitizeTriggerTemplatePayload(template),
|
|
1892
|
+
enabled: nextEnabled,
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1725
1895
|
};
|
|
1726
1896
|
|
|
1727
1897
|
const handleSaveTemplate = async (template) => {
|
|
1728
|
-
await persistUpdate({ template });
|
|
1898
|
+
await persistUpdate({ template: sanitizeTriggerTemplatePayload(template) });
|
|
1729
1899
|
};
|
|
1730
1900
|
|
|
1731
1901
|
return html`
|
|
@@ -1754,6 +1924,10 @@ function TriggerTemplatesModal({ onClose }) {
|
|
|
1754
1924
|
</label>
|
|
1755
1925
|
</div>
|
|
1756
1926
|
|
|
1927
|
+
<${Alert} severity="info" variant="outlined" sx=${{ mt: 1.25 }}>
|
|
1928
|
+
Trigger Templates are reusable automation rules. Each template watches for a trigger condition and can automatically create follow-up task work using the configured action and defaults below.
|
|
1929
|
+
</${Alert}>
|
|
1930
|
+
|
|
1757
1931
|
<div class="input-row" style="margin-top:10px;">
|
|
1758
1932
|
<${Select}
|
|
1759
1933
|
size="small"
|
|
@@ -1786,7 +1960,7 @@ function TriggerTemplatesModal({ onClose }) {
|
|
|
1786
1960
|
${!loading && templates.length === 0 && html`
|
|
1787
1961
|
<${EmptyState}
|
|
1788
1962
|
message="No trigger templates found"
|
|
1789
|
-
description="Add templates in bosun.config.json under triggerSystem.templates."
|
|
1963
|
+
description="Add templates in bosun.config.json under triggerSystem.templates. These templates define automation rules that can create follow-up task work when their trigger conditions match."
|
|
1790
1964
|
/>
|
|
1791
1965
|
`}
|
|
1792
1966
|
|
|
@@ -1889,7 +2063,13 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
1889
2063
|
let cancelled = false;
|
|
1890
2064
|
const poll = async () => {
|
|
1891
2065
|
try {
|
|
1892
|
-
const taskRes = await apiFetch(
|
|
2066
|
+
const taskRes = await apiFetch(
|
|
2067
|
+
buildTaskDetailPath(task.id, {
|
|
2068
|
+
includeDag: false,
|
|
2069
|
+
includeWorkflowRuns: false,
|
|
2070
|
+
}),
|
|
2071
|
+
{ _silent: true },
|
|
2072
|
+
);
|
|
1893
2073
|
if (!cancelled && taskRes?.data) setLiveTask(taskRes.data);
|
|
1894
2074
|
|
|
1895
2075
|
const healthRes = await apiFetch(`/api/supervisor/task/${task.id}`, { _silent: true });
|
|
@@ -2087,7 +2267,13 @@ export function TaskReviewModal({ task, onClose, onStart }) {
|
|
|
2087
2267
|
let cancelled = false;
|
|
2088
2268
|
const load = async () => {
|
|
2089
2269
|
try {
|
|
2090
|
-
const taskRes = await apiFetch(
|
|
2270
|
+
const taskRes = await apiFetch(
|
|
2271
|
+
buildTaskDetailPath(task.id, {
|
|
2272
|
+
includeDag: false,
|
|
2273
|
+
includeWorkflowRuns: false,
|
|
2274
|
+
}),
|
|
2275
|
+
{ _silent: true },
|
|
2276
|
+
);
|
|
2091
2277
|
if (!cancelled && taskRes?.data) setLiveTask(taskRes.data);
|
|
2092
2278
|
|
|
2093
2279
|
const healthRes = await apiFetch(`/api/supervisor/task/${task.id}`, { _silent: true });
|
|
@@ -2307,7 +2493,7 @@ export function TaskReviewModal({ task, onClose, onStart }) {
|
|
|
2307
2493
|
}
|
|
2308
2494
|
|
|
2309
2495
|
/* ─── TaskDetailModal ─── */
|
|
2310
|
-
export function TaskDetailModal({ task, onClose, onStart, presentation = "modal", taskCatalog = [], epicCatalog = [] }) {
|
|
2496
|
+
export function TaskDetailModal({ task, onClose, onStart, presentation = "modal", taskCatalog = [], epicCatalog = [], isHydrating = false }) {
|
|
2311
2497
|
const [title, setTitle] = useState(sanitizeTaskText(task?.title || ""));
|
|
2312
2498
|
const [description, setDescription] = useState(buildTaskDescriptionFallback(task?.title, task?.description));
|
|
2313
2499
|
const [baseBranch, setBaseBranch] = useState(getTaskBaseBranch(task));
|
|
@@ -2406,7 +2592,6 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
2406
2592
|
task?.workflowHistory,
|
|
2407
2593
|
task?.workflows,
|
|
2408
2594
|
]);
|
|
2409
|
-
|
|
2410
2595
|
// ── Execution Plan state ──────────────────────────────────────────────────
|
|
2411
2596
|
const [executionPlan, setExecutionPlan] = useState(null);
|
|
2412
2597
|
const [executionPlanLoading, setExecutionPlanLoading] = useState(false);
|
|
@@ -2434,7 +2619,67 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
2434
2619
|
.finally(() => { setExecutionPlanLoading(false); setDryRunLoading(false); });
|
|
2435
2620
|
}, [task?.id]);
|
|
2436
2621
|
|
|
2437
|
-
useEffect(() => {
|
|
2622
|
+
useEffect(() => {
|
|
2623
|
+
if (activeTab !== "execution") return;
|
|
2624
|
+
fetchExecutionPlan("resolve");
|
|
2625
|
+
}, [activeTab, fetchExecutionPlan]);
|
|
2626
|
+
|
|
2627
|
+
const handleOpenWorkflowRun = useCallback(async (run) => {
|
|
2628
|
+
try {
|
|
2629
|
+
await openTaskWorkflowRun(run);
|
|
2630
|
+
} catch {
|
|
2631
|
+
showToast("Unable to open workflow run", "error");
|
|
2632
|
+
}
|
|
2633
|
+
}, []);
|
|
2634
|
+
const handleOpenWorkflowAgentHistory = useCallback(async (run) => {
|
|
2635
|
+
try {
|
|
2636
|
+
await openTaskWorkflowAgentHistory(run);
|
|
2637
|
+
} catch {
|
|
2638
|
+
showToast("Unable to open linked agent session", "error");
|
|
2639
|
+
}
|
|
2640
|
+
}, []);
|
|
2641
|
+
const renderWorkflowActivityCard = useCallback((run, key) => {
|
|
2642
|
+
const metaLine = buildTaskWorkflowRunMetaLine(run);
|
|
2643
|
+
const statusLine = buildTaskWorkflowRunStatusLine(run);
|
|
2644
|
+
return html`
|
|
2645
|
+
<div
|
|
2646
|
+
class="task-comment-item task-workflow-run-card"
|
|
2647
|
+
key=${key}
|
|
2648
|
+
data-clickable=${run.hasRunLink ? "true" : "false"}
|
|
2649
|
+
role=${run.hasRunLink ? "button" : undefined}
|
|
2650
|
+
tabIndex=${run.hasRunLink ? 0 : undefined}
|
|
2651
|
+
onClick=${run.hasRunLink ? () => { void handleOpenWorkflowRun(run); } : undefined}
|
|
2652
|
+
onKeyDown=${run.hasRunLink
|
|
2653
|
+
? (event) => {
|
|
2654
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
2655
|
+
event.preventDefault();
|
|
2656
|
+
void handleOpenWorkflowRun(run);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
: undefined}
|
|
2660
|
+
>
|
|
2661
|
+
<div class="task-workflow-run-head">
|
|
2662
|
+
<div style="min-width:0;flex:1;">
|
|
2663
|
+
<div class="task-comment-meta">${metaLine || "workflow"}</div>
|
|
2664
|
+
<div class="task-comment-body">${statusLine}</div>
|
|
2665
|
+
${run.nodeId ? html`<div class="task-comment-meta">Node: ${run.nodeId}</div>` : null}
|
|
2666
|
+
</div>
|
|
2667
|
+
<div class="task-workflow-run-actions" onClick=${(event) => event.stopPropagation()}>
|
|
2668
|
+
${run.hasRunLink ? html`
|
|
2669
|
+
<${Button} variant="outlined" size="small" onClick=${() => { void handleOpenWorkflowRun(run); }}>
|
|
2670
|
+
Open Run
|
|
2671
|
+
<//>
|
|
2672
|
+
` : null}
|
|
2673
|
+
${run.hasSessionLink ? html`
|
|
2674
|
+
<${Button} variant="text" size="small" onClick=${() => { void handleOpenWorkflowAgentHistory(run); }}>
|
|
2675
|
+
Agent History
|
|
2676
|
+
<//>
|
|
2677
|
+
` : null}
|
|
2678
|
+
</div>
|
|
2679
|
+
</div>
|
|
2680
|
+
</div>
|
|
2681
|
+
`;
|
|
2682
|
+
}, [handleOpenWorkflowRun, handleOpenWorkflowAgentHistory]);
|
|
2438
2683
|
|
|
2439
2684
|
const toggleNodeExpand = useCallback((stageIdx, nodeId) => {
|
|
2440
2685
|
setExpandedNodes((prev) => ({ ...prev, [`${stageIdx}-${nodeId}`]: !prev[`${stageIdx}-${nodeId}`] }));
|
|
@@ -2457,6 +2702,32 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
2457
2702
|
task?.assignees,
|
|
2458
2703
|
task?.meta,
|
|
2459
2704
|
]);
|
|
2705
|
+
const taskDiagnostics = task?.diagnostics || task?.meta?.diagnostics || null;
|
|
2706
|
+
const stableCause = taskDiagnostics?.stableCause || null;
|
|
2707
|
+
const apiRecovery = taskDiagnostics?.supervisor?.apiErrorRecovery || null;
|
|
2708
|
+
const hasDiagnostics = Boolean(
|
|
2709
|
+
stableCause ||
|
|
2710
|
+
taskDiagnostics?.lastError ||
|
|
2711
|
+
taskDiagnostics?.errorPattern ||
|
|
2712
|
+
taskDiagnostics?.blockedReason ||
|
|
2713
|
+
taskDiagnostics?.cooldownUntil ||
|
|
2714
|
+
apiRecovery,
|
|
2715
|
+
);
|
|
2716
|
+
const canStartInfo = task?.canStart || task?.meta?.canStart || null;
|
|
2717
|
+
const blockedContext = task?.blockedContext || task?.meta?.blockedContext || null;
|
|
2718
|
+
const blockedBy = Array.isArray(blockedContext?.blockedBy)
|
|
2719
|
+
? blockedContext.blockedBy
|
|
2720
|
+
: Array.isArray(canStartInfo?.blockedBy)
|
|
2721
|
+
? canStartInfo.blockedBy
|
|
2722
|
+
: [];
|
|
2723
|
+
const blockedEvidence = [
|
|
2724
|
+
...(Array.isArray(blockedContext?.timelineEvidence)
|
|
2725
|
+
? blockedContext.timelineEvidence.map((entry) => ({ ...entry, kind: "timeline" }))
|
|
2726
|
+
: []),
|
|
2727
|
+
...(Array.isArray(blockedContext?.logEvidence)
|
|
2728
|
+
? blockedContext.logEvidence.map((entry) => ({ ...entry, kind: "log" }))
|
|
2729
|
+
: []),
|
|
2730
|
+
].slice(0, 6);
|
|
2460
2731
|
const lifetimeTotals = task?.lifetimeTotals
|
|
2461
2732
|
|| task?.meta?.lifetimeTotals
|
|
2462
2733
|
|| task?.runtimeSnapshot?.lifetimeTotals
|
|
@@ -3051,6 +3322,21 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3051
3322
|
}
|
|
3052
3323
|
};
|
|
3053
3324
|
|
|
3325
|
+
const handleUnblock = async () => {
|
|
3326
|
+
haptic("medium");
|
|
3327
|
+
try {
|
|
3328
|
+
await apiFetch("/api/tasks/unblock", {
|
|
3329
|
+
method: "POST",
|
|
3330
|
+
body: JSON.stringify({ taskId: task.id, status: "todo" }),
|
|
3331
|
+
});
|
|
3332
|
+
showToast("Task moved back to todo", "success");
|
|
3333
|
+
onClose();
|
|
3334
|
+
scheduleRefresh(150);
|
|
3335
|
+
} catch {
|
|
3336
|
+
/* toast */
|
|
3337
|
+
}
|
|
3338
|
+
};
|
|
3339
|
+
|
|
3054
3340
|
const handleManualToggle = async (next) => {
|
|
3055
3341
|
if (!task?.id || manualBusy) return;
|
|
3056
3342
|
if (next) {
|
|
@@ -3139,6 +3425,12 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3139
3425
|
<div class="task-detail-title-area" style="display:flex;gap:12px;align-items:flex-start;">
|
|
3140
3426
|
<div style="flex:1;min-width:0;">
|
|
3141
3427
|
<input class="task-detail-title-input" value=${title} onInput=${(e) => setTitle(e.target.value)} placeholder="Task title" />
|
|
3428
|
+
${isHydrating && html`
|
|
3429
|
+
<div class="meta-text" style=${{ marginTop: "6px", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
3430
|
+
<${CircularProgress} size=${12} thickness=${5} />
|
|
3431
|
+
<span>Refreshing task details…</span>
|
|
3432
|
+
</div>
|
|
3433
|
+
`}
|
|
3142
3434
|
</div>
|
|
3143
3435
|
<div style="display:flex;gap:6px;align-items:center;padding-top:6px;flex-shrink:0;">
|
|
3144
3436
|
<button class="task-status-btn" data-status=${status}>
|
|
@@ -3173,14 +3465,143 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3173
3465
|
</div>
|
|
3174
3466
|
|
|
3175
3467
|
${/* ── Content Body ───────────────────────────────────────────── */ ""}
|
|
3176
|
-
<div style="padding:${fullScreen ? '20px 24px' : '0'};
|
|
3468
|
+
<div style="padding:${fullScreen ? '20px 24px' : '0'};">
|
|
3177
3469
|
|
|
3178
3470
|
${/* ── DETAILS TAB — Two-column Jira layout ─────────────────── */ ""}
|
|
3179
|
-
${activeTab === "details" && html`<div class="task-detail-columns"
|
|
3471
|
+
${activeTab === "details" && html`<div class="task-detail-columns">
|
|
3180
3472
|
|
|
3181
3473
|
${/* ── LEFT: Main Content ── */ ""}
|
|
3182
3474
|
<div class="task-detail-main">
|
|
3183
3475
|
|
|
3476
|
+
${(task?.status === "blocked" || canStartInfo?.canStart === false) && html`
|
|
3477
|
+
<div class="task-section">
|
|
3478
|
+
<div class="task-section-title">
|
|
3479
|
+
${task?.status === "blocked" ? "Why Bosun Is Holding This Task" : "Why This Task Cannot Start Yet"}
|
|
3480
|
+
${blockedContext?.workflowRunCount > 0 && html`<span class="task-tab-count">${blockedContext.workflowRunCount}</span>`}
|
|
3481
|
+
</div>
|
|
3482
|
+
<div class="task-section-body">
|
|
3483
|
+
<div class="task-blocked-banner" data-category=${blockedContext?.category || "guard"}>
|
|
3484
|
+
<div class="task-blocked-banner-title">
|
|
3485
|
+
${blockedContext?.headline || "This task cannot start yet."}
|
|
3486
|
+
</div>
|
|
3487
|
+
<div class="task-blocked-banner-copy">
|
|
3488
|
+
${blockedContext?.summary || blockedContext?.reason || "Bosun paused this task because a dependency, workflow guard, or recovery issue is still unresolved."}
|
|
3489
|
+
</div>
|
|
3490
|
+
${blockedContext?.recommendation && html`
|
|
3491
|
+
<div class="task-blocked-banner-copy">${blockedContext.recommendation}</div>
|
|
3492
|
+
`}
|
|
3493
|
+
${blockedContext?.reason && blockedContext.reason !== blockedContext.summary && html`
|
|
3494
|
+
<div class="task-blocked-banner-copy">Recorded reason: ${blockedContext.reason}</div>
|
|
3495
|
+
`}
|
|
3496
|
+
</div>
|
|
3497
|
+
|
|
3498
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-top:12px;">
|
|
3499
|
+
${blockedContext?.workflowRunCount > 0 && html`
|
|
3500
|
+
<div class="task-comment-item">
|
|
3501
|
+
<div class="task-comment-meta">Workflow runs</div>
|
|
3502
|
+
<div class="task-comment-body">${blockedContext.workflowRunCount.toLocaleString("en-US")}</div>
|
|
3503
|
+
</div>
|
|
3504
|
+
`}
|
|
3505
|
+
${blockedContext?.prePrValidationFailureCount > 0 && html`
|
|
3506
|
+
<div class="task-comment-item">
|
|
3507
|
+
<div class="task-comment-meta">Validation loops</div>
|
|
3508
|
+
<div class="task-comment-body">${blockedContext.prePrValidationFailureCount.toLocaleString("en-US")} pre-PR validation failures</div>
|
|
3509
|
+
</div>
|
|
3510
|
+
`}
|
|
3511
|
+
${blockedContext?.worktreeFailureCount > 0 && html`
|
|
3512
|
+
<div class="task-comment-item">
|
|
3513
|
+
<div class="task-comment-meta">Worktree failures</div>
|
|
3514
|
+
<div class="task-comment-body">${blockedContext.worktreeFailureCount.toLocaleString("en-US")} acquisition failures</div>
|
|
3515
|
+
</div>
|
|
3516
|
+
`}
|
|
3517
|
+
${blockedBy.length > 0 && html`
|
|
3518
|
+
<div class="task-comment-item">
|
|
3519
|
+
<div class="task-comment-meta">Blocking tasks</div>
|
|
3520
|
+
<div class="task-comment-body">${blockedBy.length.toLocaleString("en-US")} unresolved dependencies</div>
|
|
3521
|
+
</div>
|
|
3522
|
+
`}
|
|
3523
|
+
</div>
|
|
3524
|
+
|
|
3525
|
+
${blockedBy.length > 0 && html`
|
|
3526
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3527
|
+
${blockedBy.map((entry, index) => html`
|
|
3528
|
+
<div class="task-comment-item" key=${`blocked-by-${index}`}>
|
|
3529
|
+
<div class="task-comment-meta">${entry.taskId || "dependency"}</div>
|
|
3530
|
+
<div class="task-comment-body">${entry.reason || "Not ready yet"}</div>
|
|
3531
|
+
</div>
|
|
3532
|
+
`)}
|
|
3533
|
+
</div>
|
|
3534
|
+
`}
|
|
3535
|
+
|
|
3536
|
+
${blockedEvidence.length > 0 && html`
|
|
3537
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3538
|
+
${blockedEvidence.map((entry, index) => html`
|
|
3539
|
+
<div class="task-comment-item" key=${`blocked-evidence-${index}`}>
|
|
3540
|
+
<div class="task-comment-meta">
|
|
3541
|
+
${entry.kind === "log" ? entry.source || "monitor log" : entry.source || "timeline"}
|
|
3542
|
+
${entry.timestamp ? ` · ${formatRelative(entry.timestamp)}` : ""}
|
|
3543
|
+
</div>
|
|
3544
|
+
<div class="task-comment-body">${entry.message}</div>
|
|
3545
|
+
</div>
|
|
3546
|
+
`)}
|
|
3547
|
+
</div>
|
|
3548
|
+
`}
|
|
3549
|
+
</div>
|
|
3550
|
+
</div>
|
|
3551
|
+
`}
|
|
3552
|
+
|
|
3553
|
+
${hasDiagnostics && html`
|
|
3554
|
+
<div class="task-section">
|
|
3555
|
+
<div class="task-section-title">Diagnostics</div>
|
|
3556
|
+
<div class="task-section-body">
|
|
3557
|
+
${stableCause && html`
|
|
3558
|
+
<div class="task-blocked-banner" data-category=${stableCause.severity || "diagnostic"}>
|
|
3559
|
+
<div class="task-blocked-banner-title">${stableCause.title || "Task diagnostics available"}</div>
|
|
3560
|
+
<div class="task-blocked-banner-copy">${stableCause.summary || "Bosun recorded a stable failure cause for this task."}</div>
|
|
3561
|
+
${stableCause.code && html`
|
|
3562
|
+
<div class="task-blocked-banner-copy">Stable cause: ${stableCause.code}</div>
|
|
3563
|
+
`}
|
|
3564
|
+
</div>
|
|
3565
|
+
`}
|
|
3566
|
+
|
|
3567
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-top:12px;">
|
|
3568
|
+
${taskDiagnostics?.errorPattern && html`
|
|
3569
|
+
<div class="task-comment-item">
|
|
3570
|
+
<div class="task-comment-meta">Error pattern</div>
|
|
3571
|
+
<div class="task-comment-body">${taskDiagnostics.errorPattern}</div>
|
|
3572
|
+
</div>
|
|
3573
|
+
`}
|
|
3574
|
+
${taskDiagnostics?.cooldownUntil && html`
|
|
3575
|
+
<div class="task-comment-item">
|
|
3576
|
+
<div class="task-comment-meta">Cooldown until</div>
|
|
3577
|
+
<div class="task-comment-body">${formatRelative(taskDiagnostics.cooldownUntil)}</div>
|
|
3578
|
+
</div>
|
|
3579
|
+
`}
|
|
3580
|
+
${apiRecovery && html`
|
|
3581
|
+
<div class="task-comment-item">
|
|
3582
|
+
<div class="task-comment-meta">Continue attempts</div>
|
|
3583
|
+
<div class="task-comment-body">${Number(apiRecovery.continueAttempts || 0).toLocaleString("en-US")}</div>
|
|
3584
|
+
</div>
|
|
3585
|
+
`}
|
|
3586
|
+
${taskDiagnostics?.blockedReason && html`
|
|
3587
|
+
<div class="task-comment-item">
|
|
3588
|
+
<div class="task-comment-meta">Blocked reason</div>
|
|
3589
|
+
<div class="task-comment-body">${taskDiagnostics.blockedReason}</div>
|
|
3590
|
+
</div>
|
|
3591
|
+
`}
|
|
3592
|
+
</div>
|
|
3593
|
+
|
|
3594
|
+
${taskDiagnostics?.lastError && html`
|
|
3595
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3596
|
+
<div class="task-comment-item">
|
|
3597
|
+
<div class="task-comment-meta">Last backend error</div>
|
|
3598
|
+
<div class="task-comment-body">${taskDiagnostics.lastError}</div>
|
|
3599
|
+
</div>
|
|
3600
|
+
</div>
|
|
3601
|
+
`}
|
|
3602
|
+
</div>
|
|
3603
|
+
</div>
|
|
3604
|
+
`}
|
|
3184
3605
|
${/* Description */ ""}
|
|
3185
3606
|
<div class="task-section">
|
|
3186
3607
|
<div class="task-section-title">Description</div>
|
|
@@ -3411,19 +3832,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3411
3832
|
<div class="task-section-title">Workflow Activity</div>
|
|
3412
3833
|
<div class="task-section-body">
|
|
3413
3834
|
<div class="task-comments-list">
|
|
3414
|
-
${workflowRuns.map((run, index) =>
|
|
3415
|
-
<div class="task-comment-item" key=${`workflow-${index}`}>
|
|
3416
|
-
<div class="task-comment-meta">
|
|
3417
|
-
${run.workflowId || "workflow"}
|
|
3418
|
-
${run.runId ? ` · run ${run.runId}` : ""}
|
|
3419
|
-
${run.timestamp ? ` · ${formatRelative(run.timestamp)}` : ""}
|
|
3420
|
-
</div>
|
|
3421
|
-
<div class="task-comment-body">${run.status || run.result || "No status summary"}</div>
|
|
3422
|
-
${run.result && run.status && run.result !== run.status && html`
|
|
3423
|
-
<div class="task-comment-body">${run.result}</div>
|
|
3424
|
-
`}
|
|
3425
|
-
</div>
|
|
3426
|
-
`)}
|
|
3835
|
+
${workflowRuns.map((run, index) => renderWorkflowActivityCard(run, `workflow-${index}`))}
|
|
3427
3836
|
</div>
|
|
3428
3837
|
</div>
|
|
3429
3838
|
</div>
|
|
@@ -3449,7 +3858,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3449
3858
|
}}
|
|
3450
3859
|
fullWidth
|
|
3451
3860
|
>
|
|
3452
|
-
${["draft", "todo", "inprogress", "inreview", "done", "cancelled"].map(
|
|
3861
|
+
${["draft", "todo", "inprogress", "inreview", "blocked", "done", "cancelled"].map(
|
|
3453
3862
|
(s) => html`<${MenuItem} value=${s}>${s}</${MenuItem}>`,
|
|
3454
3863
|
)}
|
|
3455
3864
|
</${Select}>
|
|
@@ -3796,6 +4205,9 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3796
4205
|
${(task?.status === "error" || task?.status === "cancelled") && html`
|
|
3797
4206
|
<${Button} variant="contained" size="small" onClick=${handleRetry}>↻ Retry<//>
|
|
3798
4207
|
`}
|
|
4208
|
+
${task?.status === "blocked" && html`
|
|
4209
|
+
<${Button} variant="contained" size="small" onClick=${handleUnblock}>↺ Move To Todo<//>
|
|
4210
|
+
`}
|
|
3799
4211
|
<${Button}
|
|
3800
4212
|
variant="outlined" size="small"
|
|
3801
4213
|
onClick=${() => { void handleSave({ closeAfterSave: true }); }}
|
|
@@ -4218,16 +4630,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
4218
4630
|
<div class="task-comments-block modal-form-span jira-panel">
|
|
4219
4631
|
<div class="task-attachments-title">Workflow Activity</div>
|
|
4220
4632
|
<div class="task-comments-list">
|
|
4221
|
-
${workflowRuns.map((run, index) =>
|
|
4222
|
-
<div class="task-comment-item" key=${`wf-hist-${index}`}>
|
|
4223
|
-
<div class="task-comment-meta">
|
|
4224
|
-
${run.workflowId || "workflow"}
|
|
4225
|
-
${run.runId ? ` · run ${run.runId}` : ""}
|
|
4226
|
-
${run.timestamp ? ` · ${formatRelative(run.timestamp)}` : ""}
|
|
4227
|
-
</div>
|
|
4228
|
-
<div class="task-comment-body">${run.status || run.result || "No status summary"}</div>
|
|
4229
|
-
</div>
|
|
4230
|
-
`)}
|
|
4633
|
+
${workflowRuns.map((run, index) => renderWorkflowActivityCard(run, `wf-hist-${index}`))}
|
|
4231
4634
|
</div>
|
|
4232
4635
|
</div>
|
|
4233
4636
|
`}
|
|
@@ -4474,25 +4877,6 @@ function DagGraphSection({
|
|
|
4474
4877
|
}
|
|
4475
4878
|
if (node?.taskId) onOpenTask?.(node.taskId);
|
|
4476
4879
|
}, [isWireMode, onActivateNode, onCreateEdge, onOpenTask, wireSourceId, nodeById, wiringBusy]);
|
|
4477
|
-
|
|
4478
|
-
const handleEdgeClick = useCallback((edge, event) => {
|
|
4479
|
-
event?.stopPropagation?.();
|
|
4480
|
-
if (!isWireMode || typeof onDeleteEdge !== "function") return;
|
|
4481
|
-
setSelectedEdgeKey((current) => current === edge.key ? "" : edge.key);
|
|
4482
|
-
setWireSourceId("");
|
|
4483
|
-
}, [isWireMode, onDeleteEdge]);
|
|
4484
|
-
|
|
4485
|
-
const handleDeleteSelectedEdge = useCallback(async () => {
|
|
4486
|
-
if (!selectedEdge || typeof onDeleteEdge !== "function") return;
|
|
4487
|
-
setWiringBusy(true);
|
|
4488
|
-
try {
|
|
4489
|
-
await onDeleteEdge(selectedEdge);
|
|
4490
|
-
setSelectedEdgeKey("");
|
|
4491
|
-
} finally {
|
|
4492
|
-
setWiringBusy(false);
|
|
4493
|
-
}
|
|
4494
|
-
}, [onDeleteEdge, selectedEdge]);
|
|
4495
|
-
|
|
4496
4880
|
const commitWireConnection = useCallback(async (sourceId, targetId) => {
|
|
4497
4881
|
if (!isWireMode || typeof onCreateEdge !== "function") return;
|
|
4498
4882
|
if (!sourceId || !targetId || sourceId === targetId || wiringBusy) {
|
|
@@ -4517,6 +4901,99 @@ function DagGraphSection({
|
|
|
4517
4901
|
}
|
|
4518
4902
|
}, [isWireMode, nodeById, onCreateEdge, wiringBusy]);
|
|
4519
4903
|
|
|
4904
|
+
const handleWireNodePointerDown = useCallback((node, event) => {
|
|
4905
|
+
if (!isWireMode || wiringBusy) return;
|
|
4906
|
+
const sourceId = String(node?.id || "").trim();
|
|
4907
|
+
if (!sourceId) return;
|
|
4908
|
+
event?.preventDefault?.();
|
|
4909
|
+
event?.stopPropagation?.();
|
|
4910
|
+
|
|
4911
|
+
if (typeof wireDragCleanupRef.current === "function") {
|
|
4912
|
+
wireDragCleanupRef.current();
|
|
4913
|
+
wireDragCleanupRef.current = null;
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
const dragState = {
|
|
4917
|
+
sourceId,
|
|
4918
|
+
startX: Number(event?.clientX || 0),
|
|
4919
|
+
startY: Number(event?.clientY || 0),
|
|
4920
|
+
dragging: false,
|
|
4921
|
+
};
|
|
4922
|
+
|
|
4923
|
+
const handleMove = (moveEvent) => {
|
|
4924
|
+
const nextX = Number(moveEvent?.clientX || 0);
|
|
4925
|
+
const nextY = Number(moveEvent?.clientY || 0);
|
|
4926
|
+
if (!dragState.dragging) {
|
|
4927
|
+
const deltaX = nextX - dragState.startX;
|
|
4928
|
+
const deltaY = nextY - dragState.startY;
|
|
4929
|
+
if (Math.hypot(deltaX, deltaY) < 6) return;
|
|
4930
|
+
dragState.dragging = true;
|
|
4931
|
+
setWireSourceId(sourceId);
|
|
4932
|
+
setSelectedEdgeKey("");
|
|
4933
|
+
setWireHoverId("");
|
|
4934
|
+
wireHoverIdRef.current = "";
|
|
4935
|
+
setWireDrag({ sourceId, clientX: nextX, clientY: nextY });
|
|
4936
|
+
return;
|
|
4937
|
+
}
|
|
4938
|
+
setWireDrag((current) => current
|
|
4939
|
+
? { ...current, clientX: nextX, clientY: nextY }
|
|
4940
|
+
: current);
|
|
4941
|
+
};
|
|
4942
|
+
|
|
4943
|
+
const cleanup = () => {
|
|
4944
|
+
window.removeEventListener("pointermove", handleMove);
|
|
4945
|
+
window.removeEventListener("pointerup", handleUp);
|
|
4946
|
+
window.removeEventListener("pointercancel", handleCancel);
|
|
4947
|
+
};
|
|
4948
|
+
|
|
4949
|
+
const finishWire = async () => {
|
|
4950
|
+
const targetId = wireHoverIdRef.current;
|
|
4951
|
+
setWireDrag(null);
|
|
4952
|
+
await commitWireConnection(sourceId, targetId);
|
|
4953
|
+
};
|
|
4954
|
+
|
|
4955
|
+
const handleUp = async (upEvent) => {
|
|
4956
|
+
cleanup();
|
|
4957
|
+
wireDragCleanupRef.current = null;
|
|
4958
|
+
if (dragState.dragging) {
|
|
4959
|
+
await finishWire();
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
await handleNodeClick(node, upEvent);
|
|
4963
|
+
};
|
|
4964
|
+
|
|
4965
|
+
const handleCancel = () => {
|
|
4966
|
+
cleanup();
|
|
4967
|
+
wireDragCleanupRef.current = null;
|
|
4968
|
+
setWireDrag(null);
|
|
4969
|
+
setWireHoverId("");
|
|
4970
|
+
wireHoverIdRef.current = "";
|
|
4971
|
+
};
|
|
4972
|
+
|
|
4973
|
+
wireDragCleanupRef.current = cleanup;
|
|
4974
|
+
window.addEventListener("pointermove", handleMove);
|
|
4975
|
+
window.addEventListener("pointerup", handleUp);
|
|
4976
|
+
window.addEventListener("pointercancel", handleCancel);
|
|
4977
|
+
}, [commitWireConnection, handleNodeClick, isWireMode, wiringBusy]);
|
|
4978
|
+
|
|
4979
|
+
const handleEdgeClick = useCallback((edge, event) => {
|
|
4980
|
+
event?.stopPropagation?.();
|
|
4981
|
+
if (!isWireMode || typeof onDeleteEdge !== "function") return;
|
|
4982
|
+
setSelectedEdgeKey((current) => current === edge.key ? "" : edge.key);
|
|
4983
|
+
setWireSourceId("");
|
|
4984
|
+
}, [isWireMode, onDeleteEdge]);
|
|
4985
|
+
|
|
4986
|
+
const handleDeleteSelectedEdge = useCallback(async () => {
|
|
4987
|
+
if (!selectedEdge || typeof onDeleteEdge !== "function") return;
|
|
4988
|
+
setWiringBusy(true);
|
|
4989
|
+
try {
|
|
4990
|
+
await onDeleteEdge(selectedEdge);
|
|
4991
|
+
setSelectedEdgeKey("");
|
|
4992
|
+
} finally {
|
|
4993
|
+
setWiringBusy(false);
|
|
4994
|
+
}
|
|
4995
|
+
}, [onDeleteEdge, selectedEdge]);
|
|
4996
|
+
|
|
4520
4997
|
const beginWireDrag = useCallback((node, event) => {
|
|
4521
4998
|
if (!isWireMode || typeof onCreateEdge !== "function" || wiringBusy) return;
|
|
4522
4999
|
const sourceId = String(node?.id || "").trim();
|
|
@@ -4584,7 +5061,7 @@ function DagGraphSection({
|
|
|
4584
5061
|
<div>
|
|
4585
5062
|
<div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
|
|
4586
5063
|
${description ? html`<div class="meta-text">${description}</div>` : null}
|
|
4587
|
-
<div class="meta-text">Drag to pan · wheel to zoom ·
|
|
5064
|
+
<div class="meta-text">Drag to pan · wheel to zoom · ${isWireMode ? "drag from one node to another, or click source then target, to wire edges" : "click node to open task"}.</div>
|
|
4588
5065
|
</div>
|
|
4589
5066
|
<div class="task-dag-controls">
|
|
4590
5067
|
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
|
|
@@ -4660,6 +5137,13 @@ function DagGraphSection({
|
|
|
4660
5137
|
key=${node.id}
|
|
4661
5138
|
class=${`dag-node ${selected ? "dag-node-selected" : ""} ${hoverTarget ? "dag-node-hover-target" : ""} ${highlighted ? "dag-node-highlighted" : ""}`}
|
|
4662
5139
|
onPointerDown=${(event) => event.stopPropagation()}
|
|
5140
|
+
onPointerDown=${(event) => {
|
|
5141
|
+
if (isWireMode) {
|
|
5142
|
+
handleWireNodePointerDown(node, event);
|
|
5143
|
+
return;
|
|
5144
|
+
}
|
|
5145
|
+
event.stopPropagation();
|
|
5146
|
+
}}
|
|
4663
5147
|
onPointerEnter=${() => {
|
|
4664
5148
|
if (!wireDrag || String(node.id) === String(wireDrag.sourceId)) return;
|
|
4665
5149
|
wireHoverIdRef.current = String(node.id);
|
|
@@ -4671,7 +5155,8 @@ function DagGraphSection({
|
|
|
4671
5155
|
setWireHoverId("");
|
|
4672
5156
|
}}
|
|
4673
5157
|
onClick=${(event) => handleNodeClick(node, event)}
|
|
4674
|
-
|
|
5158
|
+
onClick=${isWireMode ? undefined : (event) => handleNodeClick(node, event)}
|
|
5159
|
+
style=${{ cursor: isWireMode ? "crosshair" : node.taskId ? "pointer" : "default" }}
|
|
4675
5160
|
>
|
|
4676
5161
|
<rect
|
|
4677
5162
|
x=${pos.x}
|
|
@@ -4701,7 +5186,7 @@ function DagGraphSection({
|
|
|
4701
5186
|
fill=${selected ? "var(--accent)" : "var(--bg-canvas, #0f1115)"}
|
|
4702
5187
|
stroke="var(--accent)"
|
|
4703
5188
|
stroke-width="2"
|
|
4704
|
-
onPointerDown=${(event) =>
|
|
5189
|
+
onPointerDown=${(event) => handleWireNodePointerDown(node, event)}
|
|
4705
5190
|
/>
|
|
4706
5191
|
` : null}
|
|
4707
5192
|
${Number.isFinite(node.order) && html`<text x=${pos.x + pos.width - 16} y=${pos.y + 22} text-anchor="end" fill="var(--text-muted)" font-size="11">#${node.order}</text>`}
|
|
@@ -4719,7 +5204,9 @@ function DagGraphSection({
|
|
|
4719
5204
|
export function TasksTab() {
|
|
4720
5205
|
const [showCreate, setShowCreate] = useState(false);
|
|
4721
5206
|
const [showTemplates, setShowTemplates] = useState(false);
|
|
5207
|
+
const importInputRef = useRef(null);
|
|
4722
5208
|
const [detailTask, setDetailTask] = useState(null);
|
|
5209
|
+
const [detailTaskHydrating, setDetailTaskHydrating] = useState(false);
|
|
4723
5210
|
const [startTarget, setStartTarget] = useState(null);
|
|
4724
5211
|
const [startAnyOpen, setStartAnyOpen] = useState(false);
|
|
4725
5212
|
const [batchMode, setBatchMode] = useState(false);
|
|
@@ -4727,12 +5214,15 @@ export function TasksTab() {
|
|
|
4727
5214
|
const [isSearching, setIsSearching] = useState(false);
|
|
4728
5215
|
const [actionsOpen, setActionsOpen] = useState(false);
|
|
4729
5216
|
const [exporting, setExporting] = useState(false);
|
|
5217
|
+
const [importing, setImporting] = useState(false);
|
|
4730
5218
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
4731
5219
|
const [kanbanLoadingMore, setKanbanLoadingMore] = useState(false);
|
|
4732
5220
|
const [listSortCol, setListSortCol] = useState(""); // active column sort in list mode
|
|
4733
5221
|
const [listSortDir, setListSortDir] = useState("desc"); // "asc" | "desc"
|
|
4734
5222
|
const [dagLoading, setDagLoading] = useState(false);
|
|
4735
5223
|
const [dagError, setDagError] = useState("");
|
|
5224
|
+
const [dagOrganizeFeedback, setDagOrganizeFeedback] = useState("");
|
|
5225
|
+
const [dagOrganizeSuggestions, setDagOrganizeSuggestions] = useState([]);
|
|
4736
5226
|
const [dagSprints, setDagSprints] = useState([]);
|
|
4737
5227
|
const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
|
|
4738
5228
|
const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
|
|
@@ -4744,6 +5234,7 @@ export function TasksTab() {
|
|
|
4744
5234
|
const [dagEpicDependencies, setDagEpicDependencies] = useState([]);
|
|
4745
5235
|
const [dagFocusMode, setDagFocusMode] = useState("all");
|
|
4746
5236
|
const [showCreateSprint, setShowCreateSprint] = useState(false);
|
|
5237
|
+
const detailRequestIdRef = useRef(0);
|
|
4747
5238
|
const [editingSprint, setEditingSprint] = useState(null);
|
|
4748
5239
|
const [createSeed, setCreateSeed] = useState(null);
|
|
4749
5240
|
const [dagInteractionMode, setDagInteractionMode] = useState("open");
|
|
@@ -4812,7 +5303,7 @@ export function TasksTab() {
|
|
|
4812
5303
|
const isList = !isKanban && !isDag;
|
|
4813
5304
|
const viewModeInitRef = useRef(false);
|
|
4814
5305
|
const hasMoreKanbanPages = isKanban && page + 1 < totalPages;
|
|
4815
|
-
const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, inProgress: 0, inReview: 0, done: 0 };
|
|
5306
|
+
const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, blocked: 0, inProgress: 0, inReview: 0, done: 0 };
|
|
4816
5307
|
const boardTotalTasks = Number(tasksTotal?.value || 0);
|
|
4817
5308
|
const dagTaskCatalog = dagAllTasks.length ? dagAllTasks : tasks;
|
|
4818
5309
|
const dagPlanningState = useMemo(() => buildDagPlanningState({
|
|
@@ -4854,6 +5345,14 @@ export function TasksTab() {
|
|
|
4854
5345
|
{ id: "execution", label: "Running & review", count: dagPlanningState.counts.execution },
|
|
4855
5346
|
{ id: "ready", label: "Ready next", count: dagPlanningState.counts.ready },
|
|
4856
5347
|
];
|
|
5348
|
+
const dagOrganizeSummary = useMemo(() => {
|
|
5349
|
+
if (dagOrganizeFeedback) return dagOrganizeFeedback;
|
|
5350
|
+
return "Run Auto Wire to rewrite sprint order, add inferred dependencies, and surface any cleanup suggestions that still need review.";
|
|
5351
|
+
}, [dagOrganizeFeedback]);
|
|
5352
|
+
const dagSelectedSprintLabel = useMemo(() => {
|
|
5353
|
+
if (dagSelectedSprint === "all") return "all sprints";
|
|
5354
|
+
return dagSprints.find((entry) => entry.id === dagSelectedSprint)?.label || dagSelectedSprint;
|
|
5355
|
+
}, [dagSelectedSprint, dagSprints]);
|
|
4857
5356
|
|
|
4858
5357
|
const loadMoreKanbanTasks = useCallback(async () => {
|
|
4859
5358
|
if (!isKanban || kanbanLoadingMore || isSearching) return;
|
|
@@ -5056,6 +5555,11 @@ export function TasksTab() {
|
|
|
5056
5555
|
};
|
|
5057
5556
|
}, []);
|
|
5058
5557
|
|
|
5558
|
+
useEffect(() => {
|
|
5559
|
+
setDagOrganizeFeedback("");
|
|
5560
|
+
setDagOrganizeSuggestions([]);
|
|
5561
|
+
}, [dagSelectedSprint]);
|
|
5562
|
+
|
|
5059
5563
|
useEffect(() => {
|
|
5060
5564
|
if (isCompact) {
|
|
5061
5565
|
setFiltersOpen(false);
|
|
@@ -5099,7 +5603,7 @@ export function TasksTab() {
|
|
|
5099
5603
|
active: 0,
|
|
5100
5604
|
review: 0,
|
|
5101
5605
|
done: 0,
|
|
5102
|
-
|
|
5606
|
+
blocked: 0,
|
|
5103
5607
|
draft: 0,
|
|
5104
5608
|
};
|
|
5105
5609
|
for (const task of tasks) {
|
|
@@ -5111,7 +5615,7 @@ export function TasksTab() {
|
|
|
5111
5615
|
} else if (["done", "completed", "closed", "merged", "cancelled"].includes(status)) {
|
|
5112
5616
|
counts.done += 1;
|
|
5113
5617
|
} else if (["error", "blocked", "failed"].includes(status)) {
|
|
5114
|
-
counts.
|
|
5618
|
+
counts.blocked += 1;
|
|
5115
5619
|
} else if (["draft"].includes(status)) {
|
|
5116
5620
|
counts.draft += 1;
|
|
5117
5621
|
} else {
|
|
@@ -5123,7 +5627,7 @@ export function TasksTab() {
|
|
|
5123
5627
|
{ label: "Active", value: counts.active, color: "var(--color-inprogress)" },
|
|
5124
5628
|
{ label: "Review", value: counts.review, color: "var(--color-inreview)" },
|
|
5125
5629
|
{ label: "Done", value: counts.done, color: "var(--color-done)" },
|
|
5126
|
-
{ label: "
|
|
5630
|
+
{ label: "Blocked", value: counts.blocked, color: "var(--color-error)" },
|
|
5127
5631
|
];
|
|
5128
5632
|
}, [tasks]);
|
|
5129
5633
|
|
|
@@ -5252,6 +5756,11 @@ export function TasksTab() {
|
|
|
5252
5756
|
await refreshTab("tasks");
|
|
5253
5757
|
}, [triggerServerSearch]);
|
|
5254
5758
|
|
|
5759
|
+
const handleToggleFilters = useCallback(() => {
|
|
5760
|
+
haptic();
|
|
5761
|
+
setFiltersOpen((open) => !open);
|
|
5762
|
+
}, []);
|
|
5763
|
+
|
|
5255
5764
|
const handleRefreshDag = useCallback(async () => {
|
|
5256
5765
|
haptic("medium");
|
|
5257
5766
|
setDagLoading(true);
|
|
@@ -5266,6 +5775,49 @@ export function TasksTab() {
|
|
|
5266
5775
|
}
|
|
5267
5776
|
}, [loadDagViews]);
|
|
5268
5777
|
|
|
5778
|
+
const handleAutoOrganizeDag = useCallback(async () => {
|
|
5779
|
+
haptic("medium");
|
|
5780
|
+
setDagLoading(true);
|
|
5781
|
+
setDagError("");
|
|
5782
|
+
try {
|
|
5783
|
+
const result = await apiFetch("/api/tasks/dag/organize", {
|
|
5784
|
+
method: "POST",
|
|
5785
|
+
body: JSON.stringify(dagSelectedSprint && dagSelectedSprint !== "all"
|
|
5786
|
+
? { sprintId: dagSelectedSprint, applyDependencySuggestions: true, syncEpicDependencies: true }
|
|
5787
|
+
: { applyDependencySuggestions: true, syncEpicDependencies: true }),
|
|
5788
|
+
});
|
|
5789
|
+
const suggestions = Array.isArray(result?.suggestions) ? result.suggestions : [];
|
|
5790
|
+
const appliedDependencySuggestionCount = Number(result?.data?.appliedDependencySuggestionCount || 0);
|
|
5791
|
+
const syncedEpicDependencyCount = Number(result?.data?.syncedEpicDependencyCount || 0);
|
|
5792
|
+
const updatedTaskCount = Number(result?.data?.updatedTaskCount || 0);
|
|
5793
|
+
const updatedSprintCount = Number(result?.data?.updatedSprintCount || 0);
|
|
5794
|
+
setDagOrganizeSuggestions(suggestions);
|
|
5795
|
+
setDagOrganizeFeedback(
|
|
5796
|
+
[
|
|
5797
|
+
`Auto-wired ${dagSelectedSprintLabel}.`,
|
|
5798
|
+
updatedSprintCount > 0 ? `${updatedSprintCount} sprint order update${updatedSprintCount === 1 ? "" : "s"}.` : "",
|
|
5799
|
+
updatedTaskCount > 0 ? `${updatedTaskCount} task order update${updatedTaskCount === 1 ? "" : "s"}.` : "",
|
|
5800
|
+
appliedDependencySuggestionCount > 0 ? `${appliedDependencySuggestionCount} dependency edge${appliedDependencySuggestionCount === 1 ? "" : "s"} added.` : "",
|
|
5801
|
+
syncedEpicDependencyCount > 0 ? `${syncedEpicDependencyCount} epic dependency set${syncedEpicDependencyCount === 1 ? "" : "s"} synced.` : "",
|
|
5802
|
+
suggestions.length > 0 ? `${suggestions.length} cleanup suggestion${suggestions.length === 1 ? "" : "s"} still need review.` : "No follow-up cleanup suggestions.",
|
|
5803
|
+
].filter(Boolean).join(" "),
|
|
5804
|
+
);
|
|
5805
|
+
showToast(
|
|
5806
|
+
appliedDependencySuggestionCount > 0 || syncedEpicDependencyCount > 0
|
|
5807
|
+
? `Auto-wired DAG · ${appliedDependencySuggestionCount + syncedEpicDependencyCount} dependency update${appliedDependencySuggestionCount + syncedEpicDependencyCount === 1 ? "" : "s"}`
|
|
5808
|
+
: suggestions.length > 0
|
|
5809
|
+
? `DAG organized · ${suggestions.length} suggestions`
|
|
5810
|
+
: "DAG organized",
|
|
5811
|
+
"success",
|
|
5812
|
+
);
|
|
5813
|
+
await loadDagViews();
|
|
5814
|
+
} catch (error) {
|
|
5815
|
+
setDagError(error?.message || "Failed to organize DAG.");
|
|
5816
|
+
} finally {
|
|
5817
|
+
setDagLoading(false);
|
|
5818
|
+
}
|
|
5819
|
+
}, [dagSelectedSprint, dagSelectedSprintLabel, loadDagViews]);
|
|
5820
|
+
|
|
5269
5821
|
const handleCreateSprint = useCallback(() => {
|
|
5270
5822
|
haptic("medium");
|
|
5271
5823
|
setEditingSprint(null);
|
|
@@ -5318,26 +5870,43 @@ export function TasksTab() {
|
|
|
5318
5870
|
setDagError("Failed to update sprint execution mode.");
|
|
5319
5871
|
}
|
|
5320
5872
|
}, [dagSelectedSprint, loadDagViews]);
|
|
5873
|
+
|
|
5874
|
+
const persistSprintTaskOrder = useCallback(async (sprintId, orderedTasks) => {
|
|
5875
|
+
await Promise.all(orderedTasks.map((entry, index) => apiFetch(
|
|
5876
|
+
"/api/tasks/sprints/" + encodeURIComponent(sprintId) + "/tasks",
|
|
5877
|
+
{
|
|
5878
|
+
method: "POST",
|
|
5879
|
+
body: JSON.stringify({ taskId: entry.id, sprintOrder: index + 1 }),
|
|
5880
|
+
},
|
|
5881
|
+
)));
|
|
5882
|
+
}, []);
|
|
5883
|
+
|
|
5321
5884
|
const handleNudgeSprintTaskOrder = useCallback(async (taskId, delta) => {
|
|
5322
5885
|
const task = dagTaskCatalog.find((entry) => toText(entry?.id) === toText(taskId));
|
|
5323
5886
|
const sprintId = toText(getTaskSprintId(task));
|
|
5324
5887
|
if (!task?.id || !sprintId) return;
|
|
5325
|
-
const
|
|
5326
|
-
|
|
5888
|
+
const sprintQueue = dagSprintQueue
|
|
5889
|
+
.filter((entry) => toText(getTaskSprintId(entry)) === sprintId)
|
|
5890
|
+
.sort((left, right) => {
|
|
5891
|
+
const leftOrder = Number(getTaskSprintOrder(left) || Number.MAX_SAFE_INTEGER);
|
|
5892
|
+
const rightOrder = Number(getTaskSprintOrder(right) || Number.MAX_SAFE_INTEGER);
|
|
5893
|
+
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
|
5894
|
+
return String(left?.title || left?.id || "").localeCompare(String(right?.title || right?.id || ""));
|
|
5895
|
+
});
|
|
5896
|
+
const currentIndex = sprintQueue.findIndex((entry) => toText(entry?.id) === toText(taskId));
|
|
5897
|
+
const nextIndex = currentIndex + delta;
|
|
5898
|
+
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sprintQueue.length) return;
|
|
5899
|
+
const reordered = [...sprintQueue];
|
|
5900
|
+
const [movedTask] = reordered.splice(currentIndex, 1);
|
|
5901
|
+
reordered.splice(nextIndex, 0, movedTask);
|
|
5327
5902
|
try {
|
|
5328
|
-
await
|
|
5329
|
-
|
|
5330
|
-
{
|
|
5331
|
-
method: "POST",
|
|
5332
|
-
body: JSON.stringify({ taskId: task.id, sprintOrder: nextOrder }),
|
|
5333
|
-
},
|
|
5334
|
-
);
|
|
5335
|
-
showToast("Sprint order updated", "success");
|
|
5903
|
+
await persistSprintTaskOrder(sprintId, reordered);
|
|
5904
|
+
showToast("Sprint queue reordered", "success");
|
|
5336
5905
|
await loadDagViews();
|
|
5337
5906
|
} catch {
|
|
5338
5907
|
setDagError("Failed to update sprint task order.");
|
|
5339
5908
|
}
|
|
5340
|
-
}, [dagTaskCatalog, loadDagViews]);
|
|
5909
|
+
}, [dagSprintQueue, dagTaskCatalog, loadDagViews, persistSprintTaskOrder]);
|
|
5341
5910
|
|
|
5342
5911
|
const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
|
|
5343
5912
|
const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
|
|
@@ -5376,6 +5945,54 @@ export function TasksTab() {
|
|
|
5376
5945
|
await loadDagViews();
|
|
5377
5946
|
}, [loadDagViews]);
|
|
5378
5947
|
|
|
5948
|
+
const handleApplyDagSuggestion = useCallback(async (entry) => {
|
|
5949
|
+
const suggestionType = toText(entry?.type);
|
|
5950
|
+
if (suggestionType !== "missing_sequential_dependency") return;
|
|
5951
|
+
|
|
5952
|
+
const dependencyTaskId = toText(entry?.dependencyTaskId);
|
|
5953
|
+
const taskId = toText(entry?.taskId);
|
|
5954
|
+
if (!dependencyTaskId || !taskId || dependencyTaskId === taskId) return;
|
|
5955
|
+
|
|
5956
|
+
haptic("medium");
|
|
5957
|
+
setDagLoading(true);
|
|
5958
|
+
setDagError("");
|
|
5959
|
+
try {
|
|
5960
|
+
const task = dagTaskCatalog.find((candidate) => toText(candidate?.id) === taskId);
|
|
5961
|
+
const existing = normalizeDependencyInput(getTaskDependencyIds(task));
|
|
5962
|
+
if (existing.includes(dependencyTaskId)) {
|
|
5963
|
+
setDagOrganizeSuggestions((current) => current.filter((candidate) => !(
|
|
5964
|
+
toText(candidate?.type) === suggestionType &&
|
|
5965
|
+
toText(candidate?.taskId) === taskId &&
|
|
5966
|
+
toText(candidate?.dependencyTaskId) === dependencyTaskId
|
|
5967
|
+
)));
|
|
5968
|
+
setDagOrganizeFeedback(`Dependency ${dependencyTaskId} -> ${taskId} is already present.`);
|
|
5969
|
+
showToast("Dependency already exists", "info");
|
|
5970
|
+
return;
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5973
|
+
await apiFetch("/api/tasks/dependencies", {
|
|
5974
|
+
method: "PUT",
|
|
5975
|
+
body: JSON.stringify({
|
|
5976
|
+
taskId,
|
|
5977
|
+
dependencies: normalizeDependencyInput([...existing, dependencyTaskId]),
|
|
5978
|
+
}),
|
|
5979
|
+
});
|
|
5980
|
+
|
|
5981
|
+
setDagOrganizeSuggestions((current) => current.filter((candidate) => !(
|
|
5982
|
+
toText(candidate?.type) === suggestionType &&
|
|
5983
|
+
toText(candidate?.taskId) === taskId &&
|
|
5984
|
+
toText(candidate?.dependencyTaskId) === dependencyTaskId
|
|
5985
|
+
)));
|
|
5986
|
+
setDagOrganizeFeedback(`Applied sequential dependency ${dependencyTaskId} -> ${taskId}.`);
|
|
5987
|
+
showToast(`Applied dependency: ${dependencyTaskId} -> ${taskId}`, "success");
|
|
5988
|
+
await loadDagViews();
|
|
5989
|
+
} catch (error) {
|
|
5990
|
+
setDagError(error?.message || "Failed to apply organizer suggestion.");
|
|
5991
|
+
} finally {
|
|
5992
|
+
setDagLoading(false);
|
|
5993
|
+
}
|
|
5994
|
+
}, [dagTaskCatalog, loadDagViews]);
|
|
5995
|
+
|
|
5379
5996
|
const handleDeleteDagEdge = useCallback(async ({ sourceId, targetId, graphKind }) => {
|
|
5380
5997
|
const srcId = toText(sourceId);
|
|
5381
5998
|
const dstId = toText(targetId);
|
|
@@ -5410,32 +6027,6 @@ export function TasksTab() {
|
|
|
5410
6027
|
setDagSelectedSprint(sprintId);
|
|
5411
6028
|
}, [dagSelectedSprint]);
|
|
5412
6029
|
|
|
5413
|
-
const handleToggleFilters = () => {
|
|
5414
|
-
haptic();
|
|
5415
|
-
setFiltersOpen((prev) => {
|
|
5416
|
-
const next = !prev;
|
|
5417
|
-
if (!next) setActionsOpen(false);
|
|
5418
|
-
return next;
|
|
5419
|
-
});
|
|
5420
|
-
};
|
|
5421
|
-
|
|
5422
|
-
/* Keyboard shortcuts (mount/unmount) */
|
|
5423
|
-
useEffect(() => {
|
|
5424
|
-
const onKeyDown = (e) => {
|
|
5425
|
-
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
5426
|
-
e.preventDefault();
|
|
5427
|
-
searchRef.current?.focus?.();
|
|
5428
|
-
}
|
|
5429
|
-
if (e.key === "Escape" && searchRef.current &&
|
|
5430
|
-
document.activeElement === searchRef.current) {
|
|
5431
|
-
handleClearSearch();
|
|
5432
|
-
searchRef.current.blur();
|
|
5433
|
-
}
|
|
5434
|
-
};
|
|
5435
|
-
document.addEventListener("keydown", onKeyDown);
|
|
5436
|
-
return () => document.removeEventListener("keydown", onKeyDown);
|
|
5437
|
-
}, [handleClearSearch]);
|
|
5438
|
-
|
|
5439
6030
|
const handlePrev = async () => {
|
|
5440
6031
|
if (tasksPage) tasksPage.value = Math.max(0, page - 1);
|
|
5441
6032
|
await refreshTab("tasks");
|
|
@@ -5499,11 +6090,16 @@ export function TasksTab() {
|
|
|
5499
6090
|
const openDetail = async (taskId) => {
|
|
5500
6091
|
haptic();
|
|
5501
6092
|
const local = tasks.find((t) => t.id === taskId);
|
|
6093
|
+
const requestId = ++detailRequestIdRef.current;
|
|
6094
|
+
setDetailTask(local || { id: taskId, title: taskId, status: "todo", description: "" });
|
|
6095
|
+
setDetailTaskHydrating(true);
|
|
5502
6096
|
const result = await apiFetch(
|
|
5503
|
-
|
|
6097
|
+
buildTaskDetailPath(taskId, { includeDag: false }),
|
|
5504
6098
|
{ _silent: true },
|
|
5505
6099
|
).catch(() => ({ data: local }));
|
|
5506
|
-
|
|
6100
|
+
if (detailRequestIdRef.current !== requestId) return;
|
|
6101
|
+
setDetailTask((prev) => ({ ...(prev || {}), ...(result.data || local || {}) }));
|
|
6102
|
+
setDetailTaskHydrating(false);
|
|
5507
6103
|
};
|
|
5508
6104
|
|
|
5509
6105
|
/* ── Batch operations ── */
|
|
@@ -5587,17 +6183,80 @@ export function TasksTab() {
|
|
|
5587
6183
|
setActionsOpen(false);
|
|
5588
6184
|
haptic("medium");
|
|
5589
6185
|
try {
|
|
5590
|
-
const res = await apiFetch("/api/tasks
|
|
5591
|
-
const
|
|
6186
|
+
const res = await apiFetch("/api/tasks/export", { _silent: true });
|
|
6187
|
+
const payload = res?.data || {};
|
|
5592
6188
|
const date = new Date().toISOString().slice(0, 10);
|
|
5593
|
-
exportAsJSON(
|
|
5594
|
-
showToast(`Exported ${
|
|
6189
|
+
exportAsJSON(payload, `tasks-state-${date}.json`);
|
|
6190
|
+
showToast(`Exported ${(payload?.tasks || []).length} tasks`, "success");
|
|
5595
6191
|
} catch {
|
|
5596
6192
|
showToast("Export failed", "error");
|
|
5597
6193
|
}
|
|
5598
6194
|
setExporting(false);
|
|
5599
6195
|
};
|
|
5600
6196
|
|
|
6197
|
+
const handleImportTaskStateClick = () => {
|
|
6198
|
+
setActionsOpen(false);
|
|
6199
|
+
haptic("medium");
|
|
6200
|
+
importInputRef.current?.click?.();
|
|
6201
|
+
};
|
|
6202
|
+
|
|
6203
|
+
const handleImportTaskStateFile = async (event) => {
|
|
6204
|
+
const file = event?.target?.files?.[0] || null;
|
|
6205
|
+
if (!file) return;
|
|
6206
|
+
try {
|
|
6207
|
+
const raw = await file.text();
|
|
6208
|
+
const parsed = JSON.parse(raw);
|
|
6209
|
+
const taskList = Array.isArray(parsed)
|
|
6210
|
+
? parsed
|
|
6211
|
+
: Array.isArray(parsed?.tasks)
|
|
6212
|
+
? parsed.tasks
|
|
6213
|
+
: Array.isArray(parsed?.backlog)
|
|
6214
|
+
? parsed.backlog
|
|
6215
|
+
: Array.isArray(parsed?.data?.tasks)
|
|
6216
|
+
? parsed.data.tasks
|
|
6217
|
+
: null;
|
|
6218
|
+
if (!Array.isArray(taskList)) {
|
|
6219
|
+
throw new Error("JSON must contain an array of tasks");
|
|
6220
|
+
}
|
|
6221
|
+
|
|
6222
|
+
const ok = await showConfirm(
|
|
6223
|
+
`Import ${taskList.length} tasks from ${file.name}? Existing task IDs will be merged and missing tasks will be created.`,
|
|
6224
|
+
);
|
|
6225
|
+
if (!ok) return;
|
|
6226
|
+
|
|
6227
|
+
setImporting(true);
|
|
6228
|
+
const payload = Array.isArray(parsed)
|
|
6229
|
+
? { tasks: parsed, mode: "merge", source: { filename: file.name } }
|
|
6230
|
+
: {
|
|
6231
|
+
...parsed,
|
|
6232
|
+
tasks: taskList,
|
|
6233
|
+
mode: "merge",
|
|
6234
|
+
source: {
|
|
6235
|
+
...(parsed?.source && typeof parsed.source === "object" ? parsed.source : {}),
|
|
6236
|
+
filename: file.name,
|
|
6237
|
+
},
|
|
6238
|
+
};
|
|
6239
|
+
const res = await apiFetch("/api/tasks/import", {
|
|
6240
|
+
method: "POST",
|
|
6241
|
+
body: JSON.stringify(payload),
|
|
6242
|
+
});
|
|
6243
|
+
const summary = res?.data?.summary || {};
|
|
6244
|
+
const changedCount = Number(summary.created || 0) + Number(summary.updated || 0);
|
|
6245
|
+
showToast(
|
|
6246
|
+
`Imported ${Number(summary.created || 0)} new and updated ${Number(summary.updated || 0)} task${changedCount === 1 ? "" : "s"}${summary.failed ? ` (${summary.failed} failed)` : ""}`,
|
|
6247
|
+
summary.failed ? "warning" : "success",
|
|
6248
|
+
);
|
|
6249
|
+
scheduleRefresh(150);
|
|
6250
|
+
} catch (err) {
|
|
6251
|
+
showToast(err?.message || "Import failed", "error");
|
|
6252
|
+
} finally {
|
|
6253
|
+
setImporting(false);
|
|
6254
|
+
if (event?.target) {
|
|
6255
|
+
event.target.value = "";
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
};
|
|
6259
|
+
|
|
5601
6260
|
/* ── Render ── */
|
|
5602
6261
|
const showBatchBar = isList && batchMode && selectedIds.size > 0;
|
|
5603
6262
|
|
|
@@ -5727,7 +6386,7 @@ export function TasksTab() {
|
|
|
5727
6386
|
onClick=${() => { setActionsOpen(!actionsOpen); haptic(); }}
|
|
5728
6387
|
aria-haspopup="menu"
|
|
5729
6388
|
aria-expanded=${actionsOpen}
|
|
5730
|
-
disabled=${exporting}
|
|
6389
|
+
disabled=${exporting || importing}
|
|
5731
6390
|
>
|
|
5732
6391
|
${ICONS.ellipsis}
|
|
5733
6392
|
<span class="actions-label">Actions</span>
|
|
@@ -5745,7 +6404,8 @@ export function TasksTab() {
|
|
|
5745
6404
|
${iconText(":zap: Trigger Templates")}
|
|
5746
6405
|
<//>
|
|
5747
6406
|
<${MenuItem} onClick=${handleExportCSV}>${iconText(":chart: Export CSV")}<//>
|
|
5748
|
-
<${MenuItem} onClick=${handleExportJSON}>${iconText(":clipboard: Export JSON")}<//>
|
|
6407
|
+
<${MenuItem} onClick=${handleExportJSON}>${iconText(":clipboard: Export Task State JSON")}<//>
|
|
6408
|
+
<${MenuItem} onClick=${handleImportTaskStateClick}>${iconText(":inbox_tray: Import Task State JSON")}<//>
|
|
5749
6409
|
</div>
|
|
5750
6410
|
`}
|
|
5751
6411
|
</div>
|
|
@@ -5753,6 +6413,13 @@ export function TasksTab() {
|
|
|
5753
6413
|
|
|
5754
6414
|
return html`
|
|
5755
6415
|
<div class="sticky-search">
|
|
6416
|
+
<input
|
|
6417
|
+
ref=${importInputRef}
|
|
6418
|
+
type="file"
|
|
6419
|
+
accept="application/json,.json"
|
|
6420
|
+
style=${{ display: "none" }}
|
|
6421
|
+
onChange=${handleImportTaskStateFile}
|
|
6422
|
+
/>
|
|
5756
6423
|
<div class="tasks-toolbar">
|
|
5757
6424
|
<div class="tasks-toolbar-row">
|
|
5758
6425
|
<div class="sticky-search-main">
|
|
@@ -5934,6 +6601,13 @@ export function TasksTab() {
|
|
|
5934
6601
|
>
|
|
5935
6602
|
${dagLoading ? "Refreshing…" : "Refresh DAG"}
|
|
5936
6603
|
<//>
|
|
6604
|
+
<${Button}
|
|
6605
|
+
variant="text" size="small"
|
|
6606
|
+
onClick=${handleAutoOrganizeDag}
|
|
6607
|
+
disabled=${dagLoading}
|
|
6608
|
+
>
|
|
6609
|
+
Auto Wire
|
|
6610
|
+
<//>
|
|
5937
6611
|
<${Button}
|
|
5938
6612
|
variant="text" size="small"
|
|
5939
6613
|
onClick=${handleCreateSprint}
|
|
@@ -6084,7 +6758,7 @@ export function TasksTab() {
|
|
|
6084
6758
|
}
|
|
6085
6759
|
</style>
|
|
6086
6760
|
|
|
6087
|
-
${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} hasMoreTasks=${hasMoreKanbanPages} loadingMoreTasks=${kanbanLoadingMore} onLoadMoreTasks=${loadMoreKanbanTasks} columnTotals=${boardColumnTotals} totalTasks=${boardTotalTasks} />`}
|
|
6761
|
+
${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} hasMoreTasks=${hasMoreKanbanPages} loadingMoreTasks=${kanbanLoadingMore} onLoadMoreTasks=${loadMoreKanbanTasks} columnTotals=${boardColumnTotals} totalTasks=${boardTotalTasks} workspaceId=${activeWorkspaceId.value || ""} />`}
|
|
6088
6762
|
|
|
6089
6763
|
${isDag && html`
|
|
6090
6764
|
<div class="task-dag-shell">
|
|
@@ -6114,7 +6788,7 @@ export function TasksTab() {
|
|
|
6114
6788
|
</${ToggleButtonGroup}>
|
|
6115
6789
|
</div>
|
|
6116
6790
|
<div class="meta-text" style=${{ marginTop: "6px" }}>
|
|
6117
|
-
${dagInteractionMode === "wire" ? "
|
|
6791
|
+
${dagInteractionMode === "wire" ? "Drag from a source node to a target node to add edges, or click source then target for rapid multi-wiring." : "Click any node to open the Jira-style side panel."}
|
|
6118
6792
|
</div>
|
|
6119
6793
|
</div>
|
|
6120
6794
|
<div class="tasks-filter-section">
|
|
@@ -6129,6 +6803,46 @@ export function TasksTab() {
|
|
|
6129
6803
|
</${Select}>
|
|
6130
6804
|
</div>
|
|
6131
6805
|
</div>
|
|
6806
|
+
<div class="tasks-filter-section">
|
|
6807
|
+
<div class="tasks-filter-title">Organizer review</div>
|
|
6808
|
+
<div class="meta-text" style=${{ marginTop: "6px" }}>
|
|
6809
|
+
${dagOrganizeSummary}
|
|
6810
|
+
</div>
|
|
6811
|
+
${dagOrganizeSuggestions.length > 0 && html`
|
|
6812
|
+
<div class="meta-text" style=${{ marginTop: "4px" }}>
|
|
6813
|
+
Showing ${Math.min(dagOrganizeSuggestions.length, 6)} of ${dagOrganizeSuggestions.length} suggestion${dagOrganizeSuggestions.length === 1 ? "" : "s"} for ${dagSelectedSprintLabel}.
|
|
6814
|
+
</div>
|
|
6815
|
+
`}
|
|
6816
|
+
<div class="task-dag-sidebar-list" style=${{ marginTop: "8px" }}>
|
|
6817
|
+
${dagOrganizeSuggestions.slice(0, 6).map((entry) => {
|
|
6818
|
+
const suggestionType = toText(entry?.type, "dependency_update");
|
|
6819
|
+
const suggestionLabel = suggestionType === "missing_sequential_dependency"
|
|
6820
|
+
? "Sequential gap"
|
|
6821
|
+
: suggestionType === "redundant_transitive_dependency"
|
|
6822
|
+
? "Redundant edge"
|
|
6823
|
+
: "Dependency suggestion";
|
|
6824
|
+
const taskId = toText(entry?.taskId);
|
|
6825
|
+
const dependencyTaskId = toText(entry?.dependencyTaskId);
|
|
6826
|
+
return html`
|
|
6827
|
+
<div class="task-dag-sidebar-card">
|
|
6828
|
+
<div class="task-dag-sidebar-card-main">
|
|
6829
|
+
<strong>${suggestionLabel}</strong>
|
|
6830
|
+
<span class="meta-text">${truncate(toText(entry?.message, "Dependency rewrite suggested."), 120)}</span>
|
|
6831
|
+
<span class="meta-text">${dependencyTaskId ? `${dependencyTaskId} -> ` : ""}${taskId || "task"}</span>
|
|
6832
|
+
</div>
|
|
6833
|
+
<div class="task-dag-sidebar-card-actions">
|
|
6834
|
+
${suggestionType === "missing_sequential_dependency" && dependencyTaskId && taskId
|
|
6835
|
+
? html`<button type="button" class="task-dag-mini-btn" onClick=${() => handleApplyDagSuggestion(entry)}>apply</button>`
|
|
6836
|
+
: null}
|
|
6837
|
+
${dependencyTaskId ? html`<button type="button" class="task-dag-mini-btn" onClick=${() => openDetail(dependencyTaskId)}>dep</button>` : null}
|
|
6838
|
+
${taskId ? html`<button type="button" class="task-dag-mini-btn" onClick=${() => openDetail(taskId)}>task</button>` : null}
|
|
6839
|
+
</div>
|
|
6840
|
+
</div>
|
|
6841
|
+
`;
|
|
6842
|
+
})}
|
|
6843
|
+
${dagOrganizeSuggestions.length === 0 ? html`<div class="meta-text">No pending organizer suggestions for this scope.</div>` : null}
|
|
6844
|
+
</div>
|
|
6845
|
+
</div>
|
|
6132
6846
|
<div class="tasks-filter-section">
|
|
6133
6847
|
<div class="tasks-filter-title">Sprints</div>
|
|
6134
6848
|
<div class="task-dag-sidebar-list">
|
|
@@ -6414,14 +7128,23 @@ export function TasksTab() {
|
|
|
6414
7128
|
html`
|
|
6415
7129
|
<${TaskProgressModal}
|
|
6416
7130
|
task=${detailTask}
|
|
6417
|
-
onClose=${() =>
|
|
7131
|
+
onClose=${() => {
|
|
7132
|
+
detailRequestIdRef.current += 1;
|
|
7133
|
+
setDetailTask(null);
|
|
7134
|
+
setDetailTaskHydrating(false);
|
|
7135
|
+
}}
|
|
6418
7136
|
/>
|
|
6419
7137
|
`}
|
|
6420
7138
|
${detailTask && (isDag || !isActiveStatus(detailTask.status) || !hasLiveExecutionEvidence(detailTask)) &&
|
|
6421
7139
|
html`
|
|
6422
7140
|
<${TaskDetailModal}
|
|
6423
7141
|
task=${detailTask}
|
|
6424
|
-
|
|
7142
|
+
isHydrating=${detailTaskHydrating}
|
|
7143
|
+
onClose=${() => {
|
|
7144
|
+
detailRequestIdRef.current += 1;
|
|
7145
|
+
setDetailTask(null);
|
|
7146
|
+
setDetailTaskHydrating(false);
|
|
7147
|
+
}}
|
|
6425
7148
|
onStart=${(task) => openStartModal(task)}
|
|
6426
7149
|
presentation=${isDag ? "side-sheet" : "modal"}
|
|
6427
7150
|
taskCatalog=${dagTaskCatalog}
|
|
@@ -7031,22 +7754,6 @@ function CreateTaskModalInline({ onClose, initialValues = null, sprintOptions =
|
|
|
7031
7754
|
|
|
7032
7755
|
|
|
7033
7756
|
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
7757
|
|
|
7051
7758
|
|
|
7052
7759
|
|