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.
Files changed (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. 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: "error", label: "Error" },
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
- Errors: "error",
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({ template: { ...template, enabled: nextEnabled } });
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(`/api/tasks/detail?taskId=${task.id}`, { _silent: true });
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(`/api/tasks/detail?taskId=${task.id}`, { _silent: true });
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(() => { fetchExecutionPlan("resolve"); }, [task?.id]);
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'};overflow-y:auto;max-height:${fullScreen ? 'calc(100dvh - 140px)' : 'auto'};">
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" style="max-height:${fullScreen ? 'calc(100dvh - 160px)' : '65vh'};overflow:hidden;">
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) => html`
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) => html`
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 · click node to ${isWireMode ? "wire edges" : "open task"}.</div>
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
- style=${{ cursor: isWireMode || node.taskId ? "pointer" : "default" }}
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) => beginWireDrag(node, 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
- error: 0,
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.error += 1;
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: "Errors", value: counts.error, color: "var(--color-error)" },
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 currentOrder = Number(getTaskSprintOrder(task) || 1);
5326
- const nextOrder = Math.max(1, (Number.isFinite(currentOrder) ? currentOrder : 1) + delta);
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 apiFetch(
5329
- "/api/tasks/sprints/" + encodeURIComponent(sprintId) + "/tasks",
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
- `/api/tasks/detail?taskId=${encodeURIComponent(taskId)}`,
6097
+ buildTaskDetailPath(taskId, { includeDag: false }),
5504
6098
  { _silent: true },
5505
6099
  ).catch(() => ({ data: local }));
5506
- setDetailTask(result.data || local);
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?limit=1000", { _silent: true });
5591
- const allTasks = res?.data || res?.tasks || tasks;
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(allTasks, `tasks-${date}.json`);
5594
- showToast(`Exported ${allTasks.length} tasks`, "success");
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" ? "Click source then target to add edges." : "Click any node to open the Jira-style side panel."}
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=${() => setDetailTask(null)}
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
- onClose=${() => setDetailTask(null)}
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