bosun 0.41.2 → 0.41.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +6 -3
- 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 +28 -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/ui-server.mjs +1194 -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 +21 -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 +334 -80
- 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 +21 -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 +785 -140
- 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 +304 -52
- 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 +20 -9
- 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,21 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
2457
2702
|
task?.assignees,
|
|
2458
2703
|
task?.meta,
|
|
2459
2704
|
]);
|
|
2705
|
+
const canStartInfo = task?.canStart || task?.meta?.canStart || null;
|
|
2706
|
+
const blockedContext = task?.blockedContext || task?.meta?.blockedContext || null;
|
|
2707
|
+
const blockedBy = Array.isArray(blockedContext?.blockedBy)
|
|
2708
|
+
? blockedContext.blockedBy
|
|
2709
|
+
: Array.isArray(canStartInfo?.blockedBy)
|
|
2710
|
+
? canStartInfo.blockedBy
|
|
2711
|
+
: [];
|
|
2712
|
+
const blockedEvidence = [
|
|
2713
|
+
...(Array.isArray(blockedContext?.timelineEvidence)
|
|
2714
|
+
? blockedContext.timelineEvidence.map((entry) => ({ ...entry, kind: "timeline" }))
|
|
2715
|
+
: []),
|
|
2716
|
+
...(Array.isArray(blockedContext?.logEvidence)
|
|
2717
|
+
? blockedContext.logEvidence.map((entry) => ({ ...entry, kind: "log" }))
|
|
2718
|
+
: []),
|
|
2719
|
+
].slice(0, 6);
|
|
2460
2720
|
const lifetimeTotals = task?.lifetimeTotals
|
|
2461
2721
|
|| task?.meta?.lifetimeTotals
|
|
2462
2722
|
|| task?.runtimeSnapshot?.lifetimeTotals
|
|
@@ -3051,6 +3311,21 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3051
3311
|
}
|
|
3052
3312
|
};
|
|
3053
3313
|
|
|
3314
|
+
const handleUnblock = async () => {
|
|
3315
|
+
haptic("medium");
|
|
3316
|
+
try {
|
|
3317
|
+
await apiFetch("/api/tasks/unblock", {
|
|
3318
|
+
method: "POST",
|
|
3319
|
+
body: JSON.stringify({ taskId: task.id, status: "todo" }),
|
|
3320
|
+
});
|
|
3321
|
+
showToast("Task moved back to todo", "success");
|
|
3322
|
+
onClose();
|
|
3323
|
+
scheduleRefresh(150);
|
|
3324
|
+
} catch {
|
|
3325
|
+
/* toast */
|
|
3326
|
+
}
|
|
3327
|
+
};
|
|
3328
|
+
|
|
3054
3329
|
const handleManualToggle = async (next) => {
|
|
3055
3330
|
if (!task?.id || manualBusy) return;
|
|
3056
3331
|
if (next) {
|
|
@@ -3139,6 +3414,12 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3139
3414
|
<div class="task-detail-title-area" style="display:flex;gap:12px;align-items:flex-start;">
|
|
3140
3415
|
<div style="flex:1;min-width:0;">
|
|
3141
3416
|
<input class="task-detail-title-input" value=${title} onInput=${(e) => setTitle(e.target.value)} placeholder="Task title" />
|
|
3417
|
+
${isHydrating && html`
|
|
3418
|
+
<div class="meta-text" style=${{ marginTop: "6px", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
3419
|
+
<${CircularProgress} size=${12} thickness=${5} />
|
|
3420
|
+
<span>Refreshing task details…</span>
|
|
3421
|
+
</div>
|
|
3422
|
+
`}
|
|
3142
3423
|
</div>
|
|
3143
3424
|
<div style="display:flex;gap:6px;align-items:center;padding-top:6px;flex-shrink:0;">
|
|
3144
3425
|
<button class="task-status-btn" data-status=${status}>
|
|
@@ -3173,14 +3454,91 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3173
3454
|
</div>
|
|
3174
3455
|
|
|
3175
3456
|
${/* ── Content Body ───────────────────────────────────────────── */ ""}
|
|
3176
|
-
<div style="padding:${fullScreen ? '20px 24px' : '0'};
|
|
3457
|
+
<div style="padding:${fullScreen ? '20px 24px' : '0'};">
|
|
3177
3458
|
|
|
3178
3459
|
${/* ── DETAILS TAB — Two-column Jira layout ─────────────────── */ ""}
|
|
3179
|
-
${activeTab === "details" && html`<div class="task-detail-columns"
|
|
3460
|
+
${activeTab === "details" && html`<div class="task-detail-columns">
|
|
3180
3461
|
|
|
3181
3462
|
${/* ── LEFT: Main Content ── */ ""}
|
|
3182
3463
|
<div class="task-detail-main">
|
|
3183
3464
|
|
|
3465
|
+
${(task?.status === "blocked" || canStartInfo?.canStart === false) && html`
|
|
3466
|
+
<div class="task-section">
|
|
3467
|
+
<div class="task-section-title">
|
|
3468
|
+
${task?.status === "blocked" ? "Why Bosun Is Holding This Task" : "Why This Task Cannot Start Yet"}
|
|
3469
|
+
${blockedContext?.workflowRunCount > 0 && html`<span class="task-tab-count">${blockedContext.workflowRunCount}</span>`}
|
|
3470
|
+
</div>
|
|
3471
|
+
<div class="task-section-body">
|
|
3472
|
+
<div class="task-blocked-banner" data-category=${blockedContext?.category || "guard"}>
|
|
3473
|
+
<div class="task-blocked-banner-title">
|
|
3474
|
+
${blockedContext?.headline || "This task cannot start yet."}
|
|
3475
|
+
</div>
|
|
3476
|
+
<div class="task-blocked-banner-copy">
|
|
3477
|
+
${blockedContext?.summary || blockedContext?.reason || "Bosun paused this task because a dependency, workflow guard, or recovery issue is still unresolved."}
|
|
3478
|
+
</div>
|
|
3479
|
+
${blockedContext?.recommendation && html`
|
|
3480
|
+
<div class="task-blocked-banner-copy">${blockedContext.recommendation}</div>
|
|
3481
|
+
`}
|
|
3482
|
+
${blockedContext?.reason && blockedContext.reason !== blockedContext.summary && html`
|
|
3483
|
+
<div class="task-blocked-banner-copy">Recorded reason: ${blockedContext.reason}</div>
|
|
3484
|
+
`}
|
|
3485
|
+
</div>
|
|
3486
|
+
|
|
3487
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-top:12px;">
|
|
3488
|
+
${blockedContext?.workflowRunCount > 0 && html`
|
|
3489
|
+
<div class="task-comment-item">
|
|
3490
|
+
<div class="task-comment-meta">Workflow runs</div>
|
|
3491
|
+
<div class="task-comment-body">${blockedContext.workflowRunCount.toLocaleString("en-US")}</div>
|
|
3492
|
+
</div>
|
|
3493
|
+
`}
|
|
3494
|
+
${blockedContext?.prePrValidationFailureCount > 0 && html`
|
|
3495
|
+
<div class="task-comment-item">
|
|
3496
|
+
<div class="task-comment-meta">Validation loops</div>
|
|
3497
|
+
<div class="task-comment-body">${blockedContext.prePrValidationFailureCount.toLocaleString("en-US")} pre-PR validation failures</div>
|
|
3498
|
+
</div>
|
|
3499
|
+
`}
|
|
3500
|
+
${blockedContext?.worktreeFailureCount > 0 && html`
|
|
3501
|
+
<div class="task-comment-item">
|
|
3502
|
+
<div class="task-comment-meta">Worktree failures</div>
|
|
3503
|
+
<div class="task-comment-body">${blockedContext.worktreeFailureCount.toLocaleString("en-US")} acquisition failures</div>
|
|
3504
|
+
</div>
|
|
3505
|
+
`}
|
|
3506
|
+
${blockedBy.length > 0 && html`
|
|
3507
|
+
<div class="task-comment-item">
|
|
3508
|
+
<div class="task-comment-meta">Blocking tasks</div>
|
|
3509
|
+
<div class="task-comment-body">${blockedBy.length.toLocaleString("en-US")} unresolved dependencies</div>
|
|
3510
|
+
</div>
|
|
3511
|
+
`}
|
|
3512
|
+
</div>
|
|
3513
|
+
|
|
3514
|
+
${blockedBy.length > 0 && html`
|
|
3515
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3516
|
+
${blockedBy.map((entry, index) => html`
|
|
3517
|
+
<div class="task-comment-item" key=${`blocked-by-${index}`}>
|
|
3518
|
+
<div class="task-comment-meta">${entry.taskId || "dependency"}</div>
|
|
3519
|
+
<div class="task-comment-body">${entry.reason || "Not ready yet"}</div>
|
|
3520
|
+
</div>
|
|
3521
|
+
`)}
|
|
3522
|
+
</div>
|
|
3523
|
+
`}
|
|
3524
|
+
|
|
3525
|
+
${blockedEvidence.length > 0 && html`
|
|
3526
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3527
|
+
${blockedEvidence.map((entry, index) => html`
|
|
3528
|
+
<div class="task-comment-item" key=${`blocked-evidence-${index}`}>
|
|
3529
|
+
<div class="task-comment-meta">
|
|
3530
|
+
${entry.kind === "log" ? entry.source || "monitor log" : entry.source || "timeline"}
|
|
3531
|
+
${entry.timestamp ? ` · ${formatRelative(entry.timestamp)}` : ""}
|
|
3532
|
+
</div>
|
|
3533
|
+
<div class="task-comment-body">${entry.message}</div>
|
|
3534
|
+
</div>
|
|
3535
|
+
`)}
|
|
3536
|
+
</div>
|
|
3537
|
+
`}
|
|
3538
|
+
</div>
|
|
3539
|
+
</div>
|
|
3540
|
+
`}
|
|
3541
|
+
|
|
3184
3542
|
${/* Description */ ""}
|
|
3185
3543
|
<div class="task-section">
|
|
3186
3544
|
<div class="task-section-title">Description</div>
|
|
@@ -3411,19 +3769,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3411
3769
|
<div class="task-section-title">Workflow Activity</div>
|
|
3412
3770
|
<div class="task-section-body">
|
|
3413
3771
|
<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
|
-
`)}
|
|
3772
|
+
${workflowRuns.map((run, index) => renderWorkflowActivityCard(run, `workflow-${index}`))}
|
|
3427
3773
|
</div>
|
|
3428
3774
|
</div>
|
|
3429
3775
|
</div>
|
|
@@ -3449,7 +3795,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3449
3795
|
}}
|
|
3450
3796
|
fullWidth
|
|
3451
3797
|
>
|
|
3452
|
-
${["draft", "todo", "inprogress", "inreview", "done", "cancelled"].map(
|
|
3798
|
+
${["draft", "todo", "inprogress", "inreview", "blocked", "done", "cancelled"].map(
|
|
3453
3799
|
(s) => html`<${MenuItem} value=${s}>${s}</${MenuItem}>`,
|
|
3454
3800
|
)}
|
|
3455
3801
|
</${Select}>
|
|
@@ -3796,6 +4142,9 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3796
4142
|
${(task?.status === "error" || task?.status === "cancelled") && html`
|
|
3797
4143
|
<${Button} variant="contained" size="small" onClick=${handleRetry}>↻ Retry<//>
|
|
3798
4144
|
`}
|
|
4145
|
+
${task?.status === "blocked" && html`
|
|
4146
|
+
<${Button} variant="contained" size="small" onClick=${handleUnblock}>↺ Move To Todo<//>
|
|
4147
|
+
`}
|
|
3799
4148
|
<${Button}
|
|
3800
4149
|
variant="outlined" size="small"
|
|
3801
4150
|
onClick=${() => { void handleSave({ closeAfterSave: true }); }}
|
|
@@ -4218,16 +4567,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
4218
4567
|
<div class="task-comments-block modal-form-span jira-panel">
|
|
4219
4568
|
<div class="task-attachments-title">Workflow Activity</div>
|
|
4220
4569
|
<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
|
-
`)}
|
|
4570
|
+
${workflowRuns.map((run, index) => renderWorkflowActivityCard(run, `wf-hist-${index}`))}
|
|
4231
4571
|
</div>
|
|
4232
4572
|
</div>
|
|
4233
4573
|
`}
|
|
@@ -4474,25 +4814,6 @@ function DagGraphSection({
|
|
|
4474
4814
|
}
|
|
4475
4815
|
if (node?.taskId) onOpenTask?.(node.taskId);
|
|
4476
4816
|
}, [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
4817
|
const commitWireConnection = useCallback(async (sourceId, targetId) => {
|
|
4497
4818
|
if (!isWireMode || typeof onCreateEdge !== "function") return;
|
|
4498
4819
|
if (!sourceId || !targetId || sourceId === targetId || wiringBusy) {
|
|
@@ -4517,6 +4838,99 @@ function DagGraphSection({
|
|
|
4517
4838
|
}
|
|
4518
4839
|
}, [isWireMode, nodeById, onCreateEdge, wiringBusy]);
|
|
4519
4840
|
|
|
4841
|
+
const handleWireNodePointerDown = useCallback((node, event) => {
|
|
4842
|
+
if (!isWireMode || wiringBusy) return;
|
|
4843
|
+
const sourceId = String(node?.id || "").trim();
|
|
4844
|
+
if (!sourceId) return;
|
|
4845
|
+
event?.preventDefault?.();
|
|
4846
|
+
event?.stopPropagation?.();
|
|
4847
|
+
|
|
4848
|
+
if (typeof wireDragCleanupRef.current === "function") {
|
|
4849
|
+
wireDragCleanupRef.current();
|
|
4850
|
+
wireDragCleanupRef.current = null;
|
|
4851
|
+
}
|
|
4852
|
+
|
|
4853
|
+
const dragState = {
|
|
4854
|
+
sourceId,
|
|
4855
|
+
startX: Number(event?.clientX || 0),
|
|
4856
|
+
startY: Number(event?.clientY || 0),
|
|
4857
|
+
dragging: false,
|
|
4858
|
+
};
|
|
4859
|
+
|
|
4860
|
+
const handleMove = (moveEvent) => {
|
|
4861
|
+
const nextX = Number(moveEvent?.clientX || 0);
|
|
4862
|
+
const nextY = Number(moveEvent?.clientY || 0);
|
|
4863
|
+
if (!dragState.dragging) {
|
|
4864
|
+
const deltaX = nextX - dragState.startX;
|
|
4865
|
+
const deltaY = nextY - dragState.startY;
|
|
4866
|
+
if (Math.hypot(deltaX, deltaY) < 6) return;
|
|
4867
|
+
dragState.dragging = true;
|
|
4868
|
+
setWireSourceId(sourceId);
|
|
4869
|
+
setSelectedEdgeKey("");
|
|
4870
|
+
setWireHoverId("");
|
|
4871
|
+
wireHoverIdRef.current = "";
|
|
4872
|
+
setWireDrag({ sourceId, clientX: nextX, clientY: nextY });
|
|
4873
|
+
return;
|
|
4874
|
+
}
|
|
4875
|
+
setWireDrag((current) => current
|
|
4876
|
+
? { ...current, clientX: nextX, clientY: nextY }
|
|
4877
|
+
: current);
|
|
4878
|
+
};
|
|
4879
|
+
|
|
4880
|
+
const cleanup = () => {
|
|
4881
|
+
window.removeEventListener("pointermove", handleMove);
|
|
4882
|
+
window.removeEventListener("pointerup", handleUp);
|
|
4883
|
+
window.removeEventListener("pointercancel", handleCancel);
|
|
4884
|
+
};
|
|
4885
|
+
|
|
4886
|
+
const finishWire = async () => {
|
|
4887
|
+
const targetId = wireHoverIdRef.current;
|
|
4888
|
+
setWireDrag(null);
|
|
4889
|
+
await commitWireConnection(sourceId, targetId);
|
|
4890
|
+
};
|
|
4891
|
+
|
|
4892
|
+
const handleUp = async (upEvent) => {
|
|
4893
|
+
cleanup();
|
|
4894
|
+
wireDragCleanupRef.current = null;
|
|
4895
|
+
if (dragState.dragging) {
|
|
4896
|
+
await finishWire();
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4899
|
+
await handleNodeClick(node, upEvent);
|
|
4900
|
+
};
|
|
4901
|
+
|
|
4902
|
+
const handleCancel = () => {
|
|
4903
|
+
cleanup();
|
|
4904
|
+
wireDragCleanupRef.current = null;
|
|
4905
|
+
setWireDrag(null);
|
|
4906
|
+
setWireHoverId("");
|
|
4907
|
+
wireHoverIdRef.current = "";
|
|
4908
|
+
};
|
|
4909
|
+
|
|
4910
|
+
wireDragCleanupRef.current = cleanup;
|
|
4911
|
+
window.addEventListener("pointermove", handleMove);
|
|
4912
|
+
window.addEventListener("pointerup", handleUp);
|
|
4913
|
+
window.addEventListener("pointercancel", handleCancel);
|
|
4914
|
+
}, [commitWireConnection, handleNodeClick, isWireMode, wiringBusy]);
|
|
4915
|
+
|
|
4916
|
+
const handleEdgeClick = useCallback((edge, event) => {
|
|
4917
|
+
event?.stopPropagation?.();
|
|
4918
|
+
if (!isWireMode || typeof onDeleteEdge !== "function") return;
|
|
4919
|
+
setSelectedEdgeKey((current) => current === edge.key ? "" : edge.key);
|
|
4920
|
+
setWireSourceId("");
|
|
4921
|
+
}, [isWireMode, onDeleteEdge]);
|
|
4922
|
+
|
|
4923
|
+
const handleDeleteSelectedEdge = useCallback(async () => {
|
|
4924
|
+
if (!selectedEdge || typeof onDeleteEdge !== "function") return;
|
|
4925
|
+
setWiringBusy(true);
|
|
4926
|
+
try {
|
|
4927
|
+
await onDeleteEdge(selectedEdge);
|
|
4928
|
+
setSelectedEdgeKey("");
|
|
4929
|
+
} finally {
|
|
4930
|
+
setWiringBusy(false);
|
|
4931
|
+
}
|
|
4932
|
+
}, [onDeleteEdge, selectedEdge]);
|
|
4933
|
+
|
|
4520
4934
|
const beginWireDrag = useCallback((node, event) => {
|
|
4521
4935
|
if (!isWireMode || typeof onCreateEdge !== "function" || wiringBusy) return;
|
|
4522
4936
|
const sourceId = String(node?.id || "").trim();
|
|
@@ -4584,7 +4998,7 @@ function DagGraphSection({
|
|
|
4584
4998
|
<div>
|
|
4585
4999
|
<div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
|
|
4586
5000
|
${description ? html`<div class="meta-text">${description}</div>` : null}
|
|
4587
|
-
<div class="meta-text">Drag to pan · wheel to zoom ·
|
|
5001
|
+
<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
5002
|
</div>
|
|
4589
5003
|
<div class="task-dag-controls">
|
|
4590
5004
|
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
|
|
@@ -4660,6 +5074,13 @@ function DagGraphSection({
|
|
|
4660
5074
|
key=${node.id}
|
|
4661
5075
|
class=${`dag-node ${selected ? "dag-node-selected" : ""} ${hoverTarget ? "dag-node-hover-target" : ""} ${highlighted ? "dag-node-highlighted" : ""}`}
|
|
4662
5076
|
onPointerDown=${(event) => event.stopPropagation()}
|
|
5077
|
+
onPointerDown=${(event) => {
|
|
5078
|
+
if (isWireMode) {
|
|
5079
|
+
handleWireNodePointerDown(node, event);
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
event.stopPropagation();
|
|
5083
|
+
}}
|
|
4663
5084
|
onPointerEnter=${() => {
|
|
4664
5085
|
if (!wireDrag || String(node.id) === String(wireDrag.sourceId)) return;
|
|
4665
5086
|
wireHoverIdRef.current = String(node.id);
|
|
@@ -4671,7 +5092,8 @@ function DagGraphSection({
|
|
|
4671
5092
|
setWireHoverId("");
|
|
4672
5093
|
}}
|
|
4673
5094
|
onClick=${(event) => handleNodeClick(node, event)}
|
|
4674
|
-
|
|
5095
|
+
onClick=${isWireMode ? undefined : (event) => handleNodeClick(node, event)}
|
|
5096
|
+
style=${{ cursor: isWireMode ? "crosshair" : node.taskId ? "pointer" : "default" }}
|
|
4675
5097
|
>
|
|
4676
5098
|
<rect
|
|
4677
5099
|
x=${pos.x}
|
|
@@ -4701,7 +5123,7 @@ function DagGraphSection({
|
|
|
4701
5123
|
fill=${selected ? "var(--accent)" : "var(--bg-canvas, #0f1115)"}
|
|
4702
5124
|
stroke="var(--accent)"
|
|
4703
5125
|
stroke-width="2"
|
|
4704
|
-
onPointerDown=${(event) =>
|
|
5126
|
+
onPointerDown=${(event) => handleWireNodePointerDown(node, event)}
|
|
4705
5127
|
/>
|
|
4706
5128
|
` : null}
|
|
4707
5129
|
${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 +5141,9 @@ function DagGraphSection({
|
|
|
4719
5141
|
export function TasksTab() {
|
|
4720
5142
|
const [showCreate, setShowCreate] = useState(false);
|
|
4721
5143
|
const [showTemplates, setShowTemplates] = useState(false);
|
|
5144
|
+
const importInputRef = useRef(null);
|
|
4722
5145
|
const [detailTask, setDetailTask] = useState(null);
|
|
5146
|
+
const [detailTaskHydrating, setDetailTaskHydrating] = useState(false);
|
|
4723
5147
|
const [startTarget, setStartTarget] = useState(null);
|
|
4724
5148
|
const [startAnyOpen, setStartAnyOpen] = useState(false);
|
|
4725
5149
|
const [batchMode, setBatchMode] = useState(false);
|
|
@@ -4727,12 +5151,15 @@ export function TasksTab() {
|
|
|
4727
5151
|
const [isSearching, setIsSearching] = useState(false);
|
|
4728
5152
|
const [actionsOpen, setActionsOpen] = useState(false);
|
|
4729
5153
|
const [exporting, setExporting] = useState(false);
|
|
5154
|
+
const [importing, setImporting] = useState(false);
|
|
4730
5155
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
4731
5156
|
const [kanbanLoadingMore, setKanbanLoadingMore] = useState(false);
|
|
4732
5157
|
const [listSortCol, setListSortCol] = useState(""); // active column sort in list mode
|
|
4733
5158
|
const [listSortDir, setListSortDir] = useState("desc"); // "asc" | "desc"
|
|
4734
5159
|
const [dagLoading, setDagLoading] = useState(false);
|
|
4735
5160
|
const [dagError, setDagError] = useState("");
|
|
5161
|
+
const [dagOrganizeFeedback, setDagOrganizeFeedback] = useState("");
|
|
5162
|
+
const [dagOrganizeSuggestions, setDagOrganizeSuggestions] = useState([]);
|
|
4736
5163
|
const [dagSprints, setDagSprints] = useState([]);
|
|
4737
5164
|
const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
|
|
4738
5165
|
const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
|
|
@@ -4744,6 +5171,7 @@ export function TasksTab() {
|
|
|
4744
5171
|
const [dagEpicDependencies, setDagEpicDependencies] = useState([]);
|
|
4745
5172
|
const [dagFocusMode, setDagFocusMode] = useState("all");
|
|
4746
5173
|
const [showCreateSprint, setShowCreateSprint] = useState(false);
|
|
5174
|
+
const detailRequestIdRef = useRef(0);
|
|
4747
5175
|
const [editingSprint, setEditingSprint] = useState(null);
|
|
4748
5176
|
const [createSeed, setCreateSeed] = useState(null);
|
|
4749
5177
|
const [dagInteractionMode, setDagInteractionMode] = useState("open");
|
|
@@ -4812,7 +5240,7 @@ export function TasksTab() {
|
|
|
4812
5240
|
const isList = !isKanban && !isDag;
|
|
4813
5241
|
const viewModeInitRef = useRef(false);
|
|
4814
5242
|
const hasMoreKanbanPages = isKanban && page + 1 < totalPages;
|
|
4815
|
-
const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, inProgress: 0, inReview: 0, done: 0 };
|
|
5243
|
+
const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, blocked: 0, inProgress: 0, inReview: 0, done: 0 };
|
|
4816
5244
|
const boardTotalTasks = Number(tasksTotal?.value || 0);
|
|
4817
5245
|
const dagTaskCatalog = dagAllTasks.length ? dagAllTasks : tasks;
|
|
4818
5246
|
const dagPlanningState = useMemo(() => buildDagPlanningState({
|
|
@@ -4854,6 +5282,14 @@ export function TasksTab() {
|
|
|
4854
5282
|
{ id: "execution", label: "Running & review", count: dagPlanningState.counts.execution },
|
|
4855
5283
|
{ id: "ready", label: "Ready next", count: dagPlanningState.counts.ready },
|
|
4856
5284
|
];
|
|
5285
|
+
const dagOrganizeSummary = useMemo(() => {
|
|
5286
|
+
if (dagOrganizeFeedback) return dagOrganizeFeedback;
|
|
5287
|
+
return "Run Auto Wire to rewrite sprint order, add inferred dependencies, and surface any cleanup suggestions that still need review.";
|
|
5288
|
+
}, [dagOrganizeFeedback]);
|
|
5289
|
+
const dagSelectedSprintLabel = useMemo(() => {
|
|
5290
|
+
if (dagSelectedSprint === "all") return "all sprints";
|
|
5291
|
+
return dagSprints.find((entry) => entry.id === dagSelectedSprint)?.label || dagSelectedSprint;
|
|
5292
|
+
}, [dagSelectedSprint, dagSprints]);
|
|
4857
5293
|
|
|
4858
5294
|
const loadMoreKanbanTasks = useCallback(async () => {
|
|
4859
5295
|
if (!isKanban || kanbanLoadingMore || isSearching) return;
|
|
@@ -5056,6 +5492,11 @@ export function TasksTab() {
|
|
|
5056
5492
|
};
|
|
5057
5493
|
}, []);
|
|
5058
5494
|
|
|
5495
|
+
useEffect(() => {
|
|
5496
|
+
setDagOrganizeFeedback("");
|
|
5497
|
+
setDagOrganizeSuggestions([]);
|
|
5498
|
+
}, [dagSelectedSprint]);
|
|
5499
|
+
|
|
5059
5500
|
useEffect(() => {
|
|
5060
5501
|
if (isCompact) {
|
|
5061
5502
|
setFiltersOpen(false);
|
|
@@ -5099,7 +5540,7 @@ export function TasksTab() {
|
|
|
5099
5540
|
active: 0,
|
|
5100
5541
|
review: 0,
|
|
5101
5542
|
done: 0,
|
|
5102
|
-
|
|
5543
|
+
blocked: 0,
|
|
5103
5544
|
draft: 0,
|
|
5104
5545
|
};
|
|
5105
5546
|
for (const task of tasks) {
|
|
@@ -5111,7 +5552,7 @@ export function TasksTab() {
|
|
|
5111
5552
|
} else if (["done", "completed", "closed", "merged", "cancelled"].includes(status)) {
|
|
5112
5553
|
counts.done += 1;
|
|
5113
5554
|
} else if (["error", "blocked", "failed"].includes(status)) {
|
|
5114
|
-
counts.
|
|
5555
|
+
counts.blocked += 1;
|
|
5115
5556
|
} else if (["draft"].includes(status)) {
|
|
5116
5557
|
counts.draft += 1;
|
|
5117
5558
|
} else {
|
|
@@ -5123,7 +5564,7 @@ export function TasksTab() {
|
|
|
5123
5564
|
{ label: "Active", value: counts.active, color: "var(--color-inprogress)" },
|
|
5124
5565
|
{ label: "Review", value: counts.review, color: "var(--color-inreview)" },
|
|
5125
5566
|
{ label: "Done", value: counts.done, color: "var(--color-done)" },
|
|
5126
|
-
{ label: "
|
|
5567
|
+
{ label: "Blocked", value: counts.blocked, color: "var(--color-error)" },
|
|
5127
5568
|
];
|
|
5128
5569
|
}, [tasks]);
|
|
5129
5570
|
|
|
@@ -5252,6 +5693,11 @@ export function TasksTab() {
|
|
|
5252
5693
|
await refreshTab("tasks");
|
|
5253
5694
|
}, [triggerServerSearch]);
|
|
5254
5695
|
|
|
5696
|
+
const handleToggleFilters = useCallback(() => {
|
|
5697
|
+
haptic();
|
|
5698
|
+
setFiltersOpen((open) => !open);
|
|
5699
|
+
}, []);
|
|
5700
|
+
|
|
5255
5701
|
const handleRefreshDag = useCallback(async () => {
|
|
5256
5702
|
haptic("medium");
|
|
5257
5703
|
setDagLoading(true);
|
|
@@ -5266,6 +5712,49 @@ export function TasksTab() {
|
|
|
5266
5712
|
}
|
|
5267
5713
|
}, [loadDagViews]);
|
|
5268
5714
|
|
|
5715
|
+
const handleAutoOrganizeDag = useCallback(async () => {
|
|
5716
|
+
haptic("medium");
|
|
5717
|
+
setDagLoading(true);
|
|
5718
|
+
setDagError("");
|
|
5719
|
+
try {
|
|
5720
|
+
const result = await apiFetch("/api/tasks/dag/organize", {
|
|
5721
|
+
method: "POST",
|
|
5722
|
+
body: JSON.stringify(dagSelectedSprint && dagSelectedSprint !== "all"
|
|
5723
|
+
? { sprintId: dagSelectedSprint, applyDependencySuggestions: true, syncEpicDependencies: true }
|
|
5724
|
+
: { applyDependencySuggestions: true, syncEpicDependencies: true }),
|
|
5725
|
+
});
|
|
5726
|
+
const suggestions = Array.isArray(result?.suggestions) ? result.suggestions : [];
|
|
5727
|
+
const appliedDependencySuggestionCount = Number(result?.data?.appliedDependencySuggestionCount || 0);
|
|
5728
|
+
const syncedEpicDependencyCount = Number(result?.data?.syncedEpicDependencyCount || 0);
|
|
5729
|
+
const updatedTaskCount = Number(result?.data?.updatedTaskCount || 0);
|
|
5730
|
+
const updatedSprintCount = Number(result?.data?.updatedSprintCount || 0);
|
|
5731
|
+
setDagOrganizeSuggestions(suggestions);
|
|
5732
|
+
setDagOrganizeFeedback(
|
|
5733
|
+
[
|
|
5734
|
+
`Auto-wired ${dagSelectedSprintLabel}.`,
|
|
5735
|
+
updatedSprintCount > 0 ? `${updatedSprintCount} sprint order update${updatedSprintCount === 1 ? "" : "s"}.` : "",
|
|
5736
|
+
updatedTaskCount > 0 ? `${updatedTaskCount} task order update${updatedTaskCount === 1 ? "" : "s"}.` : "",
|
|
5737
|
+
appliedDependencySuggestionCount > 0 ? `${appliedDependencySuggestionCount} dependency edge${appliedDependencySuggestionCount === 1 ? "" : "s"} added.` : "",
|
|
5738
|
+
syncedEpicDependencyCount > 0 ? `${syncedEpicDependencyCount} epic dependency set${syncedEpicDependencyCount === 1 ? "" : "s"} synced.` : "",
|
|
5739
|
+
suggestions.length > 0 ? `${suggestions.length} cleanup suggestion${suggestions.length === 1 ? "" : "s"} still need review.` : "No follow-up cleanup suggestions.",
|
|
5740
|
+
].filter(Boolean).join(" "),
|
|
5741
|
+
);
|
|
5742
|
+
showToast(
|
|
5743
|
+
appliedDependencySuggestionCount > 0 || syncedEpicDependencyCount > 0
|
|
5744
|
+
? `Auto-wired DAG · ${appliedDependencySuggestionCount + syncedEpicDependencyCount} dependency update${appliedDependencySuggestionCount + syncedEpicDependencyCount === 1 ? "" : "s"}`
|
|
5745
|
+
: suggestions.length > 0
|
|
5746
|
+
? `DAG organized · ${suggestions.length} suggestions`
|
|
5747
|
+
: "DAG organized",
|
|
5748
|
+
"success",
|
|
5749
|
+
);
|
|
5750
|
+
await loadDagViews();
|
|
5751
|
+
} catch (error) {
|
|
5752
|
+
setDagError(error?.message || "Failed to organize DAG.");
|
|
5753
|
+
} finally {
|
|
5754
|
+
setDagLoading(false);
|
|
5755
|
+
}
|
|
5756
|
+
}, [dagSelectedSprint, dagSelectedSprintLabel, loadDagViews]);
|
|
5757
|
+
|
|
5269
5758
|
const handleCreateSprint = useCallback(() => {
|
|
5270
5759
|
haptic("medium");
|
|
5271
5760
|
setEditingSprint(null);
|
|
@@ -5318,26 +5807,43 @@ export function TasksTab() {
|
|
|
5318
5807
|
setDagError("Failed to update sprint execution mode.");
|
|
5319
5808
|
}
|
|
5320
5809
|
}, [dagSelectedSprint, loadDagViews]);
|
|
5810
|
+
|
|
5811
|
+
const persistSprintTaskOrder = useCallback(async (sprintId, orderedTasks) => {
|
|
5812
|
+
await Promise.all(orderedTasks.map((entry, index) => apiFetch(
|
|
5813
|
+
"/api/tasks/sprints/" + encodeURIComponent(sprintId) + "/tasks",
|
|
5814
|
+
{
|
|
5815
|
+
method: "POST",
|
|
5816
|
+
body: JSON.stringify({ taskId: entry.id, sprintOrder: index + 1 }),
|
|
5817
|
+
},
|
|
5818
|
+
)));
|
|
5819
|
+
}, []);
|
|
5820
|
+
|
|
5321
5821
|
const handleNudgeSprintTaskOrder = useCallback(async (taskId, delta) => {
|
|
5322
5822
|
const task = dagTaskCatalog.find((entry) => toText(entry?.id) === toText(taskId));
|
|
5323
5823
|
const sprintId = toText(getTaskSprintId(task));
|
|
5324
5824
|
if (!task?.id || !sprintId) return;
|
|
5325
|
-
const
|
|
5326
|
-
|
|
5825
|
+
const sprintQueue = dagSprintQueue
|
|
5826
|
+
.filter((entry) => toText(getTaskSprintId(entry)) === sprintId)
|
|
5827
|
+
.sort((left, right) => {
|
|
5828
|
+
const leftOrder = Number(getTaskSprintOrder(left) || Number.MAX_SAFE_INTEGER);
|
|
5829
|
+
const rightOrder = Number(getTaskSprintOrder(right) || Number.MAX_SAFE_INTEGER);
|
|
5830
|
+
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
|
5831
|
+
return String(left?.title || left?.id || "").localeCompare(String(right?.title || right?.id || ""));
|
|
5832
|
+
});
|
|
5833
|
+
const currentIndex = sprintQueue.findIndex((entry) => toText(entry?.id) === toText(taskId));
|
|
5834
|
+
const nextIndex = currentIndex + delta;
|
|
5835
|
+
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sprintQueue.length) return;
|
|
5836
|
+
const reordered = [...sprintQueue];
|
|
5837
|
+
const [movedTask] = reordered.splice(currentIndex, 1);
|
|
5838
|
+
reordered.splice(nextIndex, 0, movedTask);
|
|
5327
5839
|
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");
|
|
5840
|
+
await persistSprintTaskOrder(sprintId, reordered);
|
|
5841
|
+
showToast("Sprint queue reordered", "success");
|
|
5336
5842
|
await loadDagViews();
|
|
5337
5843
|
} catch {
|
|
5338
5844
|
setDagError("Failed to update sprint task order.");
|
|
5339
5845
|
}
|
|
5340
|
-
}, [dagTaskCatalog, loadDagViews]);
|
|
5846
|
+
}, [dagSprintQueue, dagTaskCatalog, loadDagViews, persistSprintTaskOrder]);
|
|
5341
5847
|
|
|
5342
5848
|
const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
|
|
5343
5849
|
const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
|
|
@@ -5376,6 +5882,54 @@ export function TasksTab() {
|
|
|
5376
5882
|
await loadDagViews();
|
|
5377
5883
|
}, [loadDagViews]);
|
|
5378
5884
|
|
|
5885
|
+
const handleApplyDagSuggestion = useCallback(async (entry) => {
|
|
5886
|
+
const suggestionType = toText(entry?.type);
|
|
5887
|
+
if (suggestionType !== "missing_sequential_dependency") return;
|
|
5888
|
+
|
|
5889
|
+
const dependencyTaskId = toText(entry?.dependencyTaskId);
|
|
5890
|
+
const taskId = toText(entry?.taskId);
|
|
5891
|
+
if (!dependencyTaskId || !taskId || dependencyTaskId === taskId) return;
|
|
5892
|
+
|
|
5893
|
+
haptic("medium");
|
|
5894
|
+
setDagLoading(true);
|
|
5895
|
+
setDagError("");
|
|
5896
|
+
try {
|
|
5897
|
+
const task = dagTaskCatalog.find((candidate) => toText(candidate?.id) === taskId);
|
|
5898
|
+
const existing = normalizeDependencyInput(getTaskDependencyIds(task));
|
|
5899
|
+
if (existing.includes(dependencyTaskId)) {
|
|
5900
|
+
setDagOrganizeSuggestions((current) => current.filter((candidate) => !(
|
|
5901
|
+
toText(candidate?.type) === suggestionType &&
|
|
5902
|
+
toText(candidate?.taskId) === taskId &&
|
|
5903
|
+
toText(candidate?.dependencyTaskId) === dependencyTaskId
|
|
5904
|
+
)));
|
|
5905
|
+
setDagOrganizeFeedback(`Dependency ${dependencyTaskId} -> ${taskId} is already present.`);
|
|
5906
|
+
showToast("Dependency already exists", "info");
|
|
5907
|
+
return;
|
|
5908
|
+
}
|
|
5909
|
+
|
|
5910
|
+
await apiFetch("/api/tasks/dependencies", {
|
|
5911
|
+
method: "PUT",
|
|
5912
|
+
body: JSON.stringify({
|
|
5913
|
+
taskId,
|
|
5914
|
+
dependencies: normalizeDependencyInput([...existing, dependencyTaskId]),
|
|
5915
|
+
}),
|
|
5916
|
+
});
|
|
5917
|
+
|
|
5918
|
+
setDagOrganizeSuggestions((current) => current.filter((candidate) => !(
|
|
5919
|
+
toText(candidate?.type) === suggestionType &&
|
|
5920
|
+
toText(candidate?.taskId) === taskId &&
|
|
5921
|
+
toText(candidate?.dependencyTaskId) === dependencyTaskId
|
|
5922
|
+
)));
|
|
5923
|
+
setDagOrganizeFeedback(`Applied sequential dependency ${dependencyTaskId} -> ${taskId}.`);
|
|
5924
|
+
showToast(`Applied dependency: ${dependencyTaskId} -> ${taskId}`, "success");
|
|
5925
|
+
await loadDagViews();
|
|
5926
|
+
} catch (error) {
|
|
5927
|
+
setDagError(error?.message || "Failed to apply organizer suggestion.");
|
|
5928
|
+
} finally {
|
|
5929
|
+
setDagLoading(false);
|
|
5930
|
+
}
|
|
5931
|
+
}, [dagTaskCatalog, loadDagViews]);
|
|
5932
|
+
|
|
5379
5933
|
const handleDeleteDagEdge = useCallback(async ({ sourceId, targetId, graphKind }) => {
|
|
5380
5934
|
const srcId = toText(sourceId);
|
|
5381
5935
|
const dstId = toText(targetId);
|
|
@@ -5410,32 +5964,6 @@ export function TasksTab() {
|
|
|
5410
5964
|
setDagSelectedSprint(sprintId);
|
|
5411
5965
|
}, [dagSelectedSprint]);
|
|
5412
5966
|
|
|
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
5967
|
const handlePrev = async () => {
|
|
5440
5968
|
if (tasksPage) tasksPage.value = Math.max(0, page - 1);
|
|
5441
5969
|
await refreshTab("tasks");
|
|
@@ -5499,11 +6027,16 @@ export function TasksTab() {
|
|
|
5499
6027
|
const openDetail = async (taskId) => {
|
|
5500
6028
|
haptic();
|
|
5501
6029
|
const local = tasks.find((t) => t.id === taskId);
|
|
6030
|
+
const requestId = ++detailRequestIdRef.current;
|
|
6031
|
+
setDetailTask(local || { id: taskId, title: taskId, status: "todo", description: "" });
|
|
6032
|
+
setDetailTaskHydrating(true);
|
|
5502
6033
|
const result = await apiFetch(
|
|
5503
|
-
|
|
6034
|
+
buildTaskDetailPath(taskId, { includeDag: false }),
|
|
5504
6035
|
{ _silent: true },
|
|
5505
6036
|
).catch(() => ({ data: local }));
|
|
5506
|
-
|
|
6037
|
+
if (detailRequestIdRef.current !== requestId) return;
|
|
6038
|
+
setDetailTask((prev) => ({ ...(prev || {}), ...(result.data || local || {}) }));
|
|
6039
|
+
setDetailTaskHydrating(false);
|
|
5507
6040
|
};
|
|
5508
6041
|
|
|
5509
6042
|
/* ── Batch operations ── */
|
|
@@ -5587,17 +6120,80 @@ export function TasksTab() {
|
|
|
5587
6120
|
setActionsOpen(false);
|
|
5588
6121
|
haptic("medium");
|
|
5589
6122
|
try {
|
|
5590
|
-
const res = await apiFetch("/api/tasks
|
|
5591
|
-
const
|
|
6123
|
+
const res = await apiFetch("/api/tasks/export", { _silent: true });
|
|
6124
|
+
const payload = res?.data || {};
|
|
5592
6125
|
const date = new Date().toISOString().slice(0, 10);
|
|
5593
|
-
exportAsJSON(
|
|
5594
|
-
showToast(`Exported ${
|
|
6126
|
+
exportAsJSON(payload, `tasks-state-${date}.json`);
|
|
6127
|
+
showToast(`Exported ${(payload?.tasks || []).length} tasks`, "success");
|
|
5595
6128
|
} catch {
|
|
5596
6129
|
showToast("Export failed", "error");
|
|
5597
6130
|
}
|
|
5598
6131
|
setExporting(false);
|
|
5599
6132
|
};
|
|
5600
6133
|
|
|
6134
|
+
const handleImportTaskStateClick = () => {
|
|
6135
|
+
setActionsOpen(false);
|
|
6136
|
+
haptic("medium");
|
|
6137
|
+
importInputRef.current?.click?.();
|
|
6138
|
+
};
|
|
6139
|
+
|
|
6140
|
+
const handleImportTaskStateFile = async (event) => {
|
|
6141
|
+
const file = event?.target?.files?.[0] || null;
|
|
6142
|
+
if (!file) return;
|
|
6143
|
+
try {
|
|
6144
|
+
const raw = await file.text();
|
|
6145
|
+
const parsed = JSON.parse(raw);
|
|
6146
|
+
const taskList = Array.isArray(parsed)
|
|
6147
|
+
? parsed
|
|
6148
|
+
: Array.isArray(parsed?.tasks)
|
|
6149
|
+
? parsed.tasks
|
|
6150
|
+
: Array.isArray(parsed?.backlog)
|
|
6151
|
+
? parsed.backlog
|
|
6152
|
+
: Array.isArray(parsed?.data?.tasks)
|
|
6153
|
+
? parsed.data.tasks
|
|
6154
|
+
: null;
|
|
6155
|
+
if (!Array.isArray(taskList)) {
|
|
6156
|
+
throw new Error("JSON must contain an array of tasks");
|
|
6157
|
+
}
|
|
6158
|
+
|
|
6159
|
+
const ok = await showConfirm(
|
|
6160
|
+
`Import ${taskList.length} tasks from ${file.name}? Existing task IDs will be merged and missing tasks will be created.`,
|
|
6161
|
+
);
|
|
6162
|
+
if (!ok) return;
|
|
6163
|
+
|
|
6164
|
+
setImporting(true);
|
|
6165
|
+
const payload = Array.isArray(parsed)
|
|
6166
|
+
? { tasks: parsed, mode: "merge", source: { filename: file.name } }
|
|
6167
|
+
: {
|
|
6168
|
+
...parsed,
|
|
6169
|
+
tasks: taskList,
|
|
6170
|
+
mode: "merge",
|
|
6171
|
+
source: {
|
|
6172
|
+
...(parsed?.source && typeof parsed.source === "object" ? parsed.source : {}),
|
|
6173
|
+
filename: file.name,
|
|
6174
|
+
},
|
|
6175
|
+
};
|
|
6176
|
+
const res = await apiFetch("/api/tasks/import", {
|
|
6177
|
+
method: "POST",
|
|
6178
|
+
body: JSON.stringify(payload),
|
|
6179
|
+
});
|
|
6180
|
+
const summary = res?.data?.summary || {};
|
|
6181
|
+
const changedCount = Number(summary.created || 0) + Number(summary.updated || 0);
|
|
6182
|
+
showToast(
|
|
6183
|
+
`Imported ${Number(summary.created || 0)} new and updated ${Number(summary.updated || 0)} task${changedCount === 1 ? "" : "s"}${summary.failed ? ` (${summary.failed} failed)` : ""}`,
|
|
6184
|
+
summary.failed ? "warning" : "success",
|
|
6185
|
+
);
|
|
6186
|
+
scheduleRefresh(150);
|
|
6187
|
+
} catch (err) {
|
|
6188
|
+
showToast(err?.message || "Import failed", "error");
|
|
6189
|
+
} finally {
|
|
6190
|
+
setImporting(false);
|
|
6191
|
+
if (event?.target) {
|
|
6192
|
+
event.target.value = "";
|
|
6193
|
+
}
|
|
6194
|
+
}
|
|
6195
|
+
};
|
|
6196
|
+
|
|
5601
6197
|
/* ── Render ── */
|
|
5602
6198
|
const showBatchBar = isList && batchMode && selectedIds.size > 0;
|
|
5603
6199
|
|
|
@@ -5727,7 +6323,7 @@ export function TasksTab() {
|
|
|
5727
6323
|
onClick=${() => { setActionsOpen(!actionsOpen); haptic(); }}
|
|
5728
6324
|
aria-haspopup="menu"
|
|
5729
6325
|
aria-expanded=${actionsOpen}
|
|
5730
|
-
disabled=${exporting}
|
|
6326
|
+
disabled=${exporting || importing}
|
|
5731
6327
|
>
|
|
5732
6328
|
${ICONS.ellipsis}
|
|
5733
6329
|
<span class="actions-label">Actions</span>
|
|
@@ -5745,7 +6341,8 @@ export function TasksTab() {
|
|
|
5745
6341
|
${iconText(":zap: Trigger Templates")}
|
|
5746
6342
|
<//>
|
|
5747
6343
|
<${MenuItem} onClick=${handleExportCSV}>${iconText(":chart: Export CSV")}<//>
|
|
5748
|
-
<${MenuItem} onClick=${handleExportJSON}>${iconText(":clipboard: Export JSON")}<//>
|
|
6344
|
+
<${MenuItem} onClick=${handleExportJSON}>${iconText(":clipboard: Export Task State JSON")}<//>
|
|
6345
|
+
<${MenuItem} onClick=${handleImportTaskStateClick}>${iconText(":inbox_tray: Import Task State JSON")}<//>
|
|
5749
6346
|
</div>
|
|
5750
6347
|
`}
|
|
5751
6348
|
</div>
|
|
@@ -5753,6 +6350,13 @@ export function TasksTab() {
|
|
|
5753
6350
|
|
|
5754
6351
|
return html`
|
|
5755
6352
|
<div class="sticky-search">
|
|
6353
|
+
<input
|
|
6354
|
+
ref=${importInputRef}
|
|
6355
|
+
type="file"
|
|
6356
|
+
accept="application/json,.json"
|
|
6357
|
+
style=${{ display: "none" }}
|
|
6358
|
+
onChange=${handleImportTaskStateFile}
|
|
6359
|
+
/>
|
|
5756
6360
|
<div class="tasks-toolbar">
|
|
5757
6361
|
<div class="tasks-toolbar-row">
|
|
5758
6362
|
<div class="sticky-search-main">
|
|
@@ -5934,6 +6538,13 @@ export function TasksTab() {
|
|
|
5934
6538
|
>
|
|
5935
6539
|
${dagLoading ? "Refreshing…" : "Refresh DAG"}
|
|
5936
6540
|
<//>
|
|
6541
|
+
<${Button}
|
|
6542
|
+
variant="text" size="small"
|
|
6543
|
+
onClick=${handleAutoOrganizeDag}
|
|
6544
|
+
disabled=${dagLoading}
|
|
6545
|
+
>
|
|
6546
|
+
Auto Wire
|
|
6547
|
+
<//>
|
|
5937
6548
|
<${Button}
|
|
5938
6549
|
variant="text" size="small"
|
|
5939
6550
|
onClick=${handleCreateSprint}
|
|
@@ -6084,7 +6695,7 @@ export function TasksTab() {
|
|
|
6084
6695
|
}
|
|
6085
6696
|
</style>
|
|
6086
6697
|
|
|
6087
|
-
${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} hasMoreTasks=${hasMoreKanbanPages} loadingMoreTasks=${kanbanLoadingMore} onLoadMoreTasks=${loadMoreKanbanTasks} columnTotals=${boardColumnTotals} totalTasks=${boardTotalTasks} />`}
|
|
6698
|
+
${isKanban && html`<${KanbanBoard} onOpenTask=${openDetail} hasMoreTasks=${hasMoreKanbanPages} loadingMoreTasks=${kanbanLoadingMore} onLoadMoreTasks=${loadMoreKanbanTasks} columnTotals=${boardColumnTotals} totalTasks=${boardTotalTasks} workspaceId=${activeWorkspaceId.value || ""} />`}
|
|
6088
6699
|
|
|
6089
6700
|
${isDag && html`
|
|
6090
6701
|
<div class="task-dag-shell">
|
|
@@ -6114,7 +6725,7 @@ export function TasksTab() {
|
|
|
6114
6725
|
</${ToggleButtonGroup}>
|
|
6115
6726
|
</div>
|
|
6116
6727
|
<div class="meta-text" style=${{ marginTop: "6px" }}>
|
|
6117
|
-
${dagInteractionMode === "wire" ? "
|
|
6728
|
+
${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
6729
|
</div>
|
|
6119
6730
|
</div>
|
|
6120
6731
|
<div class="tasks-filter-section">
|
|
@@ -6129,6 +6740,46 @@ export function TasksTab() {
|
|
|
6129
6740
|
</${Select}>
|
|
6130
6741
|
</div>
|
|
6131
6742
|
</div>
|
|
6743
|
+
<div class="tasks-filter-section">
|
|
6744
|
+
<div class="tasks-filter-title">Organizer review</div>
|
|
6745
|
+
<div class="meta-text" style=${{ marginTop: "6px" }}>
|
|
6746
|
+
${dagOrganizeSummary}
|
|
6747
|
+
</div>
|
|
6748
|
+
${dagOrganizeSuggestions.length > 0 && html`
|
|
6749
|
+
<div class="meta-text" style=${{ marginTop: "4px" }}>
|
|
6750
|
+
Showing ${Math.min(dagOrganizeSuggestions.length, 6)} of ${dagOrganizeSuggestions.length} suggestion${dagOrganizeSuggestions.length === 1 ? "" : "s"} for ${dagSelectedSprintLabel}.
|
|
6751
|
+
</div>
|
|
6752
|
+
`}
|
|
6753
|
+
<div class="task-dag-sidebar-list" style=${{ marginTop: "8px" }}>
|
|
6754
|
+
${dagOrganizeSuggestions.slice(0, 6).map((entry) => {
|
|
6755
|
+
const suggestionType = toText(entry?.type, "dependency_update");
|
|
6756
|
+
const suggestionLabel = suggestionType === "missing_sequential_dependency"
|
|
6757
|
+
? "Sequential gap"
|
|
6758
|
+
: suggestionType === "redundant_transitive_dependency"
|
|
6759
|
+
? "Redundant edge"
|
|
6760
|
+
: "Dependency suggestion";
|
|
6761
|
+
const taskId = toText(entry?.taskId);
|
|
6762
|
+
const dependencyTaskId = toText(entry?.dependencyTaskId);
|
|
6763
|
+
return html`
|
|
6764
|
+
<div class="task-dag-sidebar-card">
|
|
6765
|
+
<div class="task-dag-sidebar-card-main">
|
|
6766
|
+
<strong>${suggestionLabel}</strong>
|
|
6767
|
+
<span class="meta-text">${truncate(toText(entry?.message, "Dependency rewrite suggested."), 120)}</span>
|
|
6768
|
+
<span class="meta-text">${dependencyTaskId ? `${dependencyTaskId} -> ` : ""}${taskId || "task"}</span>
|
|
6769
|
+
</div>
|
|
6770
|
+
<div class="task-dag-sidebar-card-actions">
|
|
6771
|
+
${suggestionType === "missing_sequential_dependency" && dependencyTaskId && taskId
|
|
6772
|
+
? html`<button type="button" class="task-dag-mini-btn" onClick=${() => handleApplyDagSuggestion(entry)}>apply</button>`
|
|
6773
|
+
: null}
|
|
6774
|
+
${dependencyTaskId ? html`<button type="button" class="task-dag-mini-btn" onClick=${() => openDetail(dependencyTaskId)}>dep</button>` : null}
|
|
6775
|
+
${taskId ? html`<button type="button" class="task-dag-mini-btn" onClick=${() => openDetail(taskId)}>task</button>` : null}
|
|
6776
|
+
</div>
|
|
6777
|
+
</div>
|
|
6778
|
+
`;
|
|
6779
|
+
})}
|
|
6780
|
+
${dagOrganizeSuggestions.length === 0 ? html`<div class="meta-text">No pending organizer suggestions for this scope.</div>` : null}
|
|
6781
|
+
</div>
|
|
6782
|
+
</div>
|
|
6132
6783
|
<div class="tasks-filter-section">
|
|
6133
6784
|
<div class="tasks-filter-title">Sprints</div>
|
|
6134
6785
|
<div class="task-dag-sidebar-list">
|
|
@@ -6414,14 +7065,23 @@ export function TasksTab() {
|
|
|
6414
7065
|
html`
|
|
6415
7066
|
<${TaskProgressModal}
|
|
6416
7067
|
task=${detailTask}
|
|
6417
|
-
onClose=${() =>
|
|
7068
|
+
onClose=${() => {
|
|
7069
|
+
detailRequestIdRef.current += 1;
|
|
7070
|
+
setDetailTask(null);
|
|
7071
|
+
setDetailTaskHydrating(false);
|
|
7072
|
+
}}
|
|
6418
7073
|
/>
|
|
6419
7074
|
`}
|
|
6420
7075
|
${detailTask && (isDag || !isActiveStatus(detailTask.status) || !hasLiveExecutionEvidence(detailTask)) &&
|
|
6421
7076
|
html`
|
|
6422
7077
|
<${TaskDetailModal}
|
|
6423
7078
|
task=${detailTask}
|
|
6424
|
-
|
|
7079
|
+
isHydrating=${detailTaskHydrating}
|
|
7080
|
+
onClose=${() => {
|
|
7081
|
+
detailRequestIdRef.current += 1;
|
|
7082
|
+
setDetailTask(null);
|
|
7083
|
+
setDetailTaskHydrating(false);
|
|
7084
|
+
}}
|
|
6425
7085
|
onStart=${(task) => openStartModal(task)}
|
|
6426
7086
|
presentation=${isDag ? "side-sheet" : "modal"}
|
|
6427
7087
|
taskCatalog=${dagTaskCatalog}
|
|
@@ -7031,21 +7691,6 @@ function CreateTaskModalInline({ onClose, initialValues = null, sprintOptions =
|
|
|
7031
7691
|
|
|
7032
7692
|
|
|
7033
7693
|
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
7694
|
|
|
7050
7695
|
|
|
7051
7696
|
|