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