coding-agent-harness 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +9 -5
- package/README.md +12 -2
- package/README.zh-CN.md +10 -2
- package/SKILL.md +14 -3
- package/dist/build-dist.mjs +32 -6
- package/dist/check-dist-observation.mjs +73 -28
- package/dist/check-harness.mjs +0 -1
- package/dist/check-import-graph.mjs +44 -27
- package/dist/check-lite-forbidden-surfaces.mjs +121 -0
- package/dist/check-no-ts-nocheck.mjs +88 -0
- package/dist/check-runtime-emit.mjs +10 -3
- package/dist/check-type-boundaries.mjs +67 -8
- package/dist/commands/dashboard-command.mjs +52 -14
- package/dist/commands/migration-command.mjs +18 -8
- package/dist/commands/module-command.mjs +142 -0
- package/dist/commands/preset-command.mjs +65 -4
- package/dist/commands/registry.mjs +483 -0
- package/dist/commands/task-command.mjs +111 -53
- package/dist/harness.mjs +6 -303
- package/dist/lib/capability-registry.mjs +229 -53
- package/dist/lib/check-module-parallel.mjs +1 -6
- package/dist/lib/check-profiles.mjs +39 -46
- package/dist/lib/check-task-contracts.mjs +6 -4
- package/dist/lib/command-registry.mjs +248 -0
- package/dist/lib/core-shared.mjs +78 -3
- package/dist/lib/dashboard-data.mjs +203 -22
- package/dist/lib/dashboard-workbench.mjs +245 -21
- package/dist/lib/dashboard-writer.mjs +4 -1
- package/dist/lib/git-status-summary.mjs +0 -1
- package/dist/lib/governance-index-generator.mjs +7 -5
- package/dist/lib/governance-sync.mjs +46 -121
- package/dist/lib/governance-table-boundary.mjs +1 -14
- package/dist/lib/harness-core.mjs +5 -1
- package/dist/lib/harness-paths.mjs +115 -1
- package/dist/lib/impact-classifier.mjs +420 -0
- package/dist/lib/lesson-maintenance.mjs +1 -2
- package/dist/lib/markdown-utils.mjs +50 -1
- package/dist/lib/migration-planner.mjs +31 -16
- package/dist/lib/migration-support.mjs +5 -4
- package/dist/lib/module-registry.mjs +296 -0
- package/dist/lib/preset-audit-contracts.mjs +24 -1
- package/dist/lib/preset-engine.mjs +68 -29
- package/dist/lib/preset-registry.mjs +374 -72
- package/dist/lib/preset-runner.mjs +560 -0
- package/dist/lib/review-confirm-git-gate.mjs +73 -19
- package/dist/lib/status-builder.mjs +23 -8
- package/dist/lib/structure-migration.mjs +6 -4
- package/dist/lib/subagent-authorization-audit.mjs +8 -2
- package/dist/lib/task-archive-eligibility.mjs +65 -0
- package/dist/lib/task-audit-metadata.mjs +25 -11
- package/dist/lib/task-audit-migration.mjs +21 -14
- package/dist/lib/task-discovery-contract.mjs +32 -0
- package/dist/lib/task-index.mjs +4 -2
- package/dist/lib/task-lesson-candidates.mjs +1 -2
- package/dist/lib/task-lesson-sedimentation.mjs +310 -9
- package/dist/lib/task-lifecycle/create-task-helpers.mjs +6 -3
- package/dist/lib/task-lifecycle/phase-sync.mjs +0 -1
- package/dist/lib/task-lifecycle/preset-interop.mjs +16 -0
- package/dist/lib/task-lifecycle/review-confirm.mjs +34 -2
- package/dist/lib/task-lifecycle/review-gates.mjs +12 -5
- package/dist/lib/task-lifecycle/review-submission.mjs +1 -2
- package/dist/lib/task-lifecycle/scaffold-provenance.mjs +0 -1
- package/dist/lib/task-lifecycle/template-files.mjs +2 -5
- package/dist/lib/task-lifecycle.mjs +117 -159
- package/dist/lib/task-metadata.mjs +10 -5
- package/dist/lib/task-preset-contract-drift.mjs +45 -0
- package/dist/lib/task-repository.mjs +192 -0
- package/dist/lib/task-review-model.mjs +38 -17
- package/dist/lib/task-scanner.mjs +75 -23
- package/dist/lib/task-template-materials.mjs +131 -0
- package/dist/lib/task-tombstone-commands.mjs +187 -18
- package/dist/lib/types/check-profiles.js +1 -0
- package/dist/lib/types/impact.js +1 -0
- package/dist/lib/types/preset.js +1 -0
- package/dist/lib/types/task-lifecycle.js +1 -0
- package/dist/lib/types/task-scanner.js +1 -0
- package/dist/postinstall.mjs +2 -2
- package/dist/run-built-tests.mjs +10 -3
- package/docs-release/README.md +2 -1
- package/docs-release/architecture/document-contract-kernel/README.md +150 -0
- package/docs-release/architecture/document-contract-kernel/products/full-skill-overlay.md +29 -0
- package/docs-release/architecture/document-contract-kernel/products/lite-forbidden-surfaces.txt +26 -0
- package/docs-release/architecture/document-contract-kernel/products/lite-skill-overlay.md +37 -0
- package/docs-release/architecture/overview.md +2 -2
- package/docs-release/architecture/overview.zh-CN.md +2 -2
- package/docs-release/architecture/system-explainer/01-system-overview.md +11 -7
- package/docs-release/architecture/system-explainer/02-module-dependency.md +4 -4
- package/docs-release/architecture/system-explainer/03-task-lifecycle.md +17 -12
- package/docs-release/architecture/system-explainer/05-data-flow.md +6 -6
- package/docs-release/architecture/system-explainer/06-preset-and-migration.md +2 -2
- package/docs-release/architecture/system-explainer/README.md +1 -1
- package/docs-release/architecture/system-explainer/en-US/01-system-overview.md +12 -8
- package/docs-release/architecture/system-explainer/en-US/02-module-dependency.md +5 -5
- package/docs-release/architecture/system-explainer/en-US/03-task-lifecycle.md +19 -11
- package/docs-release/architecture/system-explainer/en-US/05-data-flow.md +5 -5
- package/docs-release/architecture/system-explainer/en-US/06-preset-and-migration.md +2 -2
- package/docs-release/architecture/system-explainer/en-US/README.md +1 -1
- package/docs-release/guides/agent-installation.en-US.md +4 -6
- package/docs-release/guides/agent-installation.md +11 -8
- package/docs-release/guides/contributing.md +10 -3
- package/docs-release/guides/contributing.zh-CN.md +10 -3
- package/docs-release/guides/legacy-migration-agent-prompt.md +1 -1
- package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +1 -1
- package/docs-release/guides/migration-playbook.en-US.md +9 -6
- package/docs-release/guides/migration-playbook.md +9 -6
- package/docs-release/guides/preset-development.md +68 -2
- package/docs-release/guides/task-state-machine.en-US.md +8 -8
- package/docs-release/guides/task-state-machine.md +7 -7
- package/docs-release/guides/typescript-runtime-migration-closeout.md +17 -13
- package/package.json +19 -11
- package/postinstall.mjs +37 -0
- package/presets/legacy-migration/preset.yaml +5 -5
- package/presets/legacy-migration/templates/execution_strategy.append.md +1 -1
- package/presets/lesson-sedimentation/preset.yaml +3 -3
- package/presets/module/preset.yaml +2 -2
- package/presets/module/templates/execution_strategy.append.md +1 -1
- package/presets/module/templates/task_plan.append.md +3 -3
- package/presets/release-closeout/checks/check-release-package.mjs +29 -0
- package/presets/release-closeout/preset.yaml +100 -0
- package/presets/release-closeout/scripts/generate-release-package.mjs +572 -0
- package/presets/release-closeout/templates/execution_strategy.append.md +7 -0
- package/presets/release-closeout/templates/findings.seed.md +5 -0
- package/presets/release-closeout/templates/review.seed.md +3 -0
- package/presets/release-closeout/templates/task_plan.append.md +24 -0
- package/presets/standard-task/preset.yaml +2 -2
- package/references/agents-md-pattern.md +23 -17
- package/references/lessons-governance.md +2 -2
- package/references/module-parallel-standard.md +3 -6
- package/references/pull-request-standard.md +2 -2
- package/references/ssot-governance.md +2 -2
- package/references/taskr-gap-analysis.md +3 -3
- package/run-dist.mjs +34 -0
- package/skills/preset-creator/SKILL.md +40 -8
- package/skills/preset-creator/references/complex-task-skeleton/brief.md +32 -8
- package/skills/preset-creator/references/preset-package-skeleton.md +15 -5
- package/skills/preset-creator/references/structure-aware-paths.md +112 -0
- package/templates/AGENTS.md.template +28 -26
- package/templates/architecture/README.md +2 -2
- package/templates/architecture/service-catalog.md +2 -2
- package/templates/architecture/services/service-template.md +1 -1
- package/templates/dashboard/assets/app-src/00-state.js +5 -1
- package/templates/dashboard/assets/app-src/10-router.js +7 -0
- package/templates/dashboard/assets/app-src/20-overview.js +8 -8
- package/templates/dashboard/assets/app-src/30-tasks.js +132 -40
- package/templates/dashboard/assets/app-src/32-task-swimlane.js +314 -0
- package/templates/dashboard/assets/app-src/35-task-detail.js +35 -5
- package/templates/dashboard/assets/app-src/40-modules.js +257 -41
- package/templates/dashboard/assets/app-src/45-review.js +127 -1
- package/templates/dashboard/assets/app-src/90-bindings.js +185 -2
- package/templates/dashboard/assets/app.css +928 -53
- package/templates/dashboard/assets/app.css.manifest.json +2 -0
- package/templates/dashboard/assets/app.js +1071 -98
- package/templates/dashboard/assets/app.manifest.json +1 -0
- package/templates/dashboard/assets/css-src/00-foundation.css +12 -6
- package/templates/dashboard/assets/css-src/10-panels-flow.css +2 -2
- package/templates/dashboard/assets/css-src/30-task-index.css +21 -13
- package/templates/dashboard/assets/css-src/31-archive.css +94 -0
- package/templates/dashboard/assets/css-src/32-task-swimlane.css +487 -0
- package/templates/dashboard/assets/css-src/35-review-workspace.css +78 -0
- package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +191 -14
- package/templates/dashboard/assets/css-src/50-responsive-overrides.css +23 -0
- package/templates/dashboard/assets/i18n.js +166 -2
- package/templates/development/README.md +9 -9
- package/templates/development/cross-repo-debugging.md +3 -3
- package/templates/development/external-context/service-template.md +1 -1
- package/templates/development/external-source-packs/README.md +2 -2
- package/templates/integrations/README.md +4 -4
- package/templates/integrations/api-contract.md +1 -1
- package/templates/integrations/event-contract.md +1 -1
- package/templates/integrations/third-party/vendor-template.md +1 -1
- package/templates/integrations/webhook-contract.md +1 -1
- package/templates/ledger/Harness-Ledger.md +1 -1
- package/templates/modules/module_brief.md +50 -0
- package/templates/modules/module_plan.md +49 -0
- package/templates/modules/registry_view.md +9 -0
- package/templates/modules/session_prompt_pack.md +55 -0
- package/templates/planning/brief.md +32 -8
- package/templates/planning/module_brief.md +28 -3
- package/templates/planning/module_plan.md +26 -11
- package/templates/planning/module_session_prompt.md +11 -2
- package/templates/planning/optional/slices/_slice-template/brief.md +28 -0
- package/templates/planning/review.md +1 -1
- package/templates/planning/visual_map.md +1 -1
- package/templates/reference/docs-library-standard.md +7 -7
- package/templates/reference/execution-workflow-standard.md +13 -0
- package/templates/reference/external-source-intake-standard.md +10 -10
- package/templates/reference/pull-request-standard.md +2 -2
- package/templates/reference/repo-governance-standard.md +1 -1
- package/templates/reference/review-routing-standard.md +4 -0
- package/templates/ssot/Module-Registry.md +4 -38
- package/templates/walkthrough/walkthrough-template.md +1 -1
- package/templates-zh-CN/AGENTS.md.template +27 -25
- package/templates-zh-CN/CLAUDE.md.template +1 -1
- package/templates-zh-CN/architecture/README.md +2 -2
- package/templates-zh-CN/architecture/service-catalog.md +2 -2
- package/templates-zh-CN/architecture/services/service-template.md +1 -1
- package/templates-zh-CN/development/README.md +9 -9
- package/templates-zh-CN/development/cross-repo-debugging.md +3 -3
- package/templates-zh-CN/development/external-context/service-template.md +1 -1
- package/templates-zh-CN/development/external-source-packs/README.md +2 -2
- package/templates-zh-CN/integrations/README.md +4 -4
- package/templates-zh-CN/integrations/api-contract.md +1 -1
- package/templates-zh-CN/integrations/event-contract.md +1 -1
- package/templates-zh-CN/integrations/third-party/vendor-template.md +1 -1
- package/templates-zh-CN/integrations/webhook-contract.md +1 -1
- package/templates-zh-CN/ledger/Harness-Ledger.md +1 -1
- package/templates-zh-CN/lessons/lesson-arch-process-change.md +1 -1
- package/templates-zh-CN/lessons/lesson-new-doc.md +3 -3
- package/templates-zh-CN/lessons/lesson-ref-change.md +4 -4
- package/templates-zh-CN/modules/module_brief.md +47 -0
- package/templates-zh-CN/modules/module_plan.md +48 -0
- package/templates-zh-CN/modules/registry_view.md +9 -0
- package/templates-zh-CN/modules/session_prompt_pack.md +50 -0
- package/templates-zh-CN/planning/INDEX.md +1 -0
- package/templates-zh-CN/planning/brief.md +26 -7
- package/templates-zh-CN/planning/module_brief.md +24 -2
- package/templates-zh-CN/planning/module_plan.md +35 -29
- package/templates-zh-CN/planning/module_session_prompt.md +15 -11
- package/templates-zh-CN/planning/optional/slices/_slice-template/brief.md +28 -11
- package/templates-zh-CN/planning/review.md +1 -1
- package/templates-zh-CN/reference/adversarial-review-standard.md +1 -1
- package/templates-zh-CN/reference/delivery-operating-model-standard.md +3 -3
- package/templates-zh-CN/reference/docs-library-standard.md +27 -27
- package/templates-zh-CN/reference/execution-workflow-standard.md +12 -2
- package/templates-zh-CN/reference/external-source-intake-standard.md +10 -10
- package/templates-zh-CN/reference/harness-ledger-standard.md +3 -3
- package/templates-zh-CN/reference/pull-request-standard.md +1 -1
- package/templates-zh-CN/reference/regression-ssot-governance.md +2 -2
- package/templates-zh-CN/reference/repo-governance-standard.md +1 -1
- package/templates-zh-CN/reference/review-routing-standard.md +3 -0
- package/templates-zh-CN/reference/walkthrough-standard.md +2 -2
- package/templates-zh-CN/reference/worktree-standard.md +1 -1
- package/templates-zh-CN/regression/Cadence-Ledger.md +2 -2
- package/templates-zh-CN/ssot/Delivery-SSoT.md +2 -2
- package/templates-zh-CN/ssot/Module-Registry.md +5 -44
- package/templates-zh-CN/ssot/Regression-SSoT.md +2 -2
- package/templates-zh-CN/walkthrough/walkthrough-template.md +4 -4
|
@@ -7,7 +7,7 @@ let labels = window.HarnessI18n?.[locale] || {};
|
|
|
7
7
|
const state = {
|
|
8
8
|
query: "",
|
|
9
9
|
taskState: "all",
|
|
10
|
-
taskGroupMode: "migration",
|
|
10
|
+
taskGroupMode: localStorage.getItem("harness.taskGroupMode") || ((Array.isArray(bundle.modules) && bundle.modules.length > 0) ? "module" : "migration"),
|
|
11
11
|
taskPageByGroup: {},
|
|
12
12
|
taskGroupPage: 1,
|
|
13
13
|
warningFilter: "all",
|
|
@@ -24,6 +24,10 @@ const state = {
|
|
|
24
24
|
presetSeedForce: false,
|
|
25
25
|
presetUninstallScope: "project",
|
|
26
26
|
presetUninstallConfirm: "",
|
|
27
|
+
reviewBulkSelection: {},
|
|
28
|
+
reviewBulkResult: null,
|
|
29
|
+
lessonBulkSelection: {},
|
|
30
|
+
lessonBulkResult: null,
|
|
27
31
|
renderMode: "rendered",
|
|
28
32
|
theme: localStorage.getItem("harness.theme") || "system",
|
|
29
33
|
taskLayout: localStorage.getItem("harness.taskLayout") || "list",
|
|
@@ -57,6 +61,10 @@ function t(key) {
|
|
|
57
61
|
return labels[key] || key;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
function formatMessage(key, values = {}) {
|
|
65
|
+
return escapeHtml(t(key)).replace(/\{([^}]+)\}/g, (_, name) => escapeHtml(values[name] ?? ""));
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
function setLocale(nextLocale) {
|
|
61
69
|
locale = window.HarnessI18n?.[nextLocale] ? nextLocale : "en";
|
|
62
70
|
labels = window.HarnessI18n?.[locale] || {};
|
|
@@ -83,6 +91,7 @@ function shell() {
|
|
|
83
91
|
${routeLink("#/", t("overview"), "overview")}
|
|
84
92
|
${routeLink("#/tasks", t("taskIndex"), "tasks")}
|
|
85
93
|
${routeLink("#/review", t("reviewQueue"), "review")}
|
|
94
|
+
${routeLink("#/archive", t("archive"), "archive")}
|
|
86
95
|
${routeLink("#/modules", t("moduleView"), "modules")}
|
|
87
96
|
${routeLink("#/presets", t("presetCatalog"), "presets")}
|
|
88
97
|
<button data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
|
|
@@ -110,6 +119,7 @@ function renderRoute() {
|
|
|
110
119
|
if (route.name === "task") return taskDetail(route);
|
|
111
120
|
if (route.name === "reviewTask") return reviewWorkspace(route);
|
|
112
121
|
if (route.name === "review") return reviewQueue();
|
|
122
|
+
if (route.name === "archive") return archiveView();
|
|
113
123
|
if (route.name === "modules") return modulesView(route.id);
|
|
114
124
|
if (route.name === "presets") return presetsView();
|
|
115
125
|
if (route.name === "tasks") return taskIndex();
|
|
@@ -122,6 +132,7 @@ function currentRoute() {
|
|
|
122
132
|
if (parts[0] === "tasks" && parts[1]) return { name: "task", id: parts[1], doc: parts[2] === "docs" ? parts[3] || "" : "" };
|
|
123
133
|
if (parts[0] === "review" && parts[1]) return { name: "reviewTask", id: parts[1] };
|
|
124
134
|
if (parts[0] === "review") return { name: "review" };
|
|
135
|
+
if (parts[0] === "archive") return { name: "archive" };
|
|
125
136
|
if (parts[0] === "modules") return { name: "modules", id: parts[1] || "" };
|
|
126
137
|
if (parts[0] === "presets") return { name: "presets" };
|
|
127
138
|
if (parts[0] === "tasks") return { name: "tasks" };
|
|
@@ -157,7 +168,7 @@ function statusStrip() {
|
|
|
157
168
|
const displayState = snapshotOnly ? "snapshot" : status;
|
|
158
169
|
const failures = bundle.status?.checkState?.failures || 0;
|
|
159
170
|
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
160
|
-
const tasks =
|
|
171
|
+
const tasks = normalCycleTasks();
|
|
161
172
|
const summary = bundle.status?.summary || {};
|
|
162
173
|
const visual = summary.visualMapCoverage || {};
|
|
163
174
|
const withBrief = tasks.filter((task) => task.briefSource === "standalone").length;
|
|
@@ -189,7 +200,7 @@ function nextActionText() {
|
|
|
189
200
|
if (dataOnly && !isWorkbenchRuntime()) return t("snapshotNotValidated");
|
|
190
201
|
const failures = bundle.status?.checkState?.failures || 0;
|
|
191
202
|
if (failures > 0) return t("resolveBlockers");
|
|
192
|
-
const missingBriefs = (
|
|
203
|
+
const missingBriefs = normalCycleTasks().filter((task) => task.briefSource !== "standalone").length;
|
|
193
204
|
if (missingBriefs > 0) return `${missingBriefs} ${t("missingBriefs")}`;
|
|
194
205
|
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
195
206
|
if (warnings > 0) return t("reviewAdvice");
|
|
@@ -202,7 +213,7 @@ function isWorkbenchRuntime() {
|
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
function flowPanel() {
|
|
205
|
-
const tasks =
|
|
216
|
+
const tasks = normalCycleTasks();
|
|
206
217
|
const total = tasks.length;
|
|
207
218
|
if (total === 0) return "";
|
|
208
219
|
const active = tasks.filter((task) => isActiveTaskState(task.state)).length;
|
|
@@ -261,14 +272,14 @@ function projectMermaid() {
|
|
|
261
272
|
|
|
262
273
|
function usesAggregateFlow() {
|
|
263
274
|
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
264
|
-
const taskCount = (
|
|
275
|
+
const taskCount = normalCycleTasks().length;
|
|
265
276
|
const taskNodes = (graph.nodes || []).filter((node) => node.type === "task").length;
|
|
266
277
|
const usefulEdges = (graph.edges || []).filter((edge) => ["depends_on", "current_step"].includes(edge.type)).length;
|
|
267
278
|
return taskCount > 80 || taskNodes > 80 || ((graph.nodes || []).length > 80 && usefulEdges < 6);
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
function migrationAggregateMermaid() {
|
|
271
|
-
const tasks =
|
|
282
|
+
const tasks = normalCycleTasks();
|
|
272
283
|
const warnings = warningQueue();
|
|
273
284
|
const activeContracts = warnings.filter((warning) => warning.phase === "active-task-contracts").length;
|
|
274
285
|
const moduleCount = new Set(tasks.map(taskModuleKey)).size;
|
|
@@ -284,7 +295,7 @@ function migrationAggregateMermaid() {
|
|
|
284
295
|
}
|
|
285
296
|
|
|
286
297
|
function migrationRunwayBreakdown() {
|
|
287
|
-
const tasks =
|
|
298
|
+
const tasks = normalCycleTasks();
|
|
288
299
|
const warnings = warningQueue();
|
|
289
300
|
const phases = [
|
|
290
301
|
["baseline", t("runwayBaseline"), tasks.length, t("tasks"), "#/tasks"],
|
|
@@ -306,7 +317,7 @@ function mermaidFromBriefs() {
|
|
|
306
317
|
|
|
307
318
|
function graphSummary() {
|
|
308
319
|
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
309
|
-
if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${(
|
|
320
|
+
if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${normalCycleTasks().length} ${t("tasks")}`;
|
|
310
321
|
return `${graph.nodes?.length || 0} ${t("nodes")} · ${graph.edges?.length || 0} ${t("edges")}`;
|
|
311
322
|
}
|
|
312
323
|
|
|
@@ -330,7 +341,7 @@ function activeTaskBriefs() {
|
|
|
330
341
|
}
|
|
331
342
|
|
|
332
343
|
function activeTasks() {
|
|
333
|
-
const tasks =
|
|
344
|
+
const tasks = normalCycleTasks();
|
|
334
345
|
const active = tasks.filter((task) => isActiveTaskState(task.state) || ["planned", "not_started"].includes(task.state));
|
|
335
346
|
if (active.length > 0) return sortTasksByTime(active);
|
|
336
347
|
return sortTasksByTime(tasks.filter((task) => task.briefSource === "standalone"));
|
|
@@ -396,6 +407,22 @@ function stateToColorVar(state) {
|
|
|
396
407
|
return map[state] || "--muted";
|
|
397
408
|
}
|
|
398
409
|
|
|
410
|
+
function taskStatRows(tasks) {
|
|
411
|
+
return [
|
|
412
|
+
{ state: "in_progress", label: t("statInProgress"), className: "in-progress" },
|
|
413
|
+
{ state: "review", label: t("statReview"), className: "review" },
|
|
414
|
+
{ state: "blocked", label: t("statBlocked"), className: "blocked" },
|
|
415
|
+
{ state: "done", label: t("statDone"), className: "done" },
|
|
416
|
+
{ state: "planned", label: label("planned"), className: "planned" },
|
|
417
|
+
{ state: "not_started", label: label("not_started"), className: "not-started" },
|
|
418
|
+
{ state: "unknown", label: label("unknown"), className: "unknown" },
|
|
419
|
+
].map((row) => ({
|
|
420
|
+
...row,
|
|
421
|
+
count: tasks.filter((task) => task.state === row.state).length,
|
|
422
|
+
colorVar: stateToColorVar(row.state),
|
|
423
|
+
})).filter((row) => row.count > 0);
|
|
424
|
+
}
|
|
425
|
+
|
|
399
426
|
function taskSortLabel() {
|
|
400
427
|
return state.taskSortOrder === "asc" ? t("sortOldest") : t("sortNewest");
|
|
401
428
|
}
|
|
@@ -430,6 +457,23 @@ function sortTasksByTime(tasks) {
|
|
|
430
457
|
return [...tasks].sort(compareTasksByTime);
|
|
431
458
|
}
|
|
432
459
|
|
|
460
|
+
function isArchivedTask(task) {
|
|
461
|
+
const archiveState = String(task?.archiveMetadata?.state || "").toLowerCase();
|
|
462
|
+
return task?.deletionState === "archived" || archiveState === "archived";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function normalCycleTasks() {
|
|
466
|
+
return (bundle.status?.tasks || []).filter((task) => !isArchivedTask(task));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function archivedTasks() {
|
|
470
|
+
return (bundle.status?.tasks || []).filter(isArchivedTask);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function archiveBucket(task) {
|
|
474
|
+
return task?.archiveMetadata?.["retention bucket"] || task?.archiveMetadata?.["Retention Bucket"] || t("archiveUnclassified");
|
|
475
|
+
}
|
|
476
|
+
|
|
433
477
|
function taskFolderName(task) {
|
|
434
478
|
const fromPath = String(task?.path || "").split("/").filter(Boolean).pop();
|
|
435
479
|
const fromId = String(task?.id || "").split("/").filter(Boolean).pop();
|
|
@@ -458,7 +502,7 @@ function taskToolbarCard(filteredCount) {
|
|
|
458
502
|
<div class="select-group">
|
|
459
503
|
<label>${t("stateFilter")}</label>
|
|
460
504
|
<select data-state-filter aria-label="${t("stateFilter")}">
|
|
461
|
-
${["all", "in_progress", "review", "blocked", "planned", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
|
|
505
|
+
${["all", "in_progress", "review", "blocked", "planned", "not_started", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
|
|
462
506
|
</select>
|
|
463
507
|
</div>
|
|
464
508
|
<div class="select-group">
|
|
@@ -478,6 +522,10 @@ function taskToolbarCard(filteredCount) {
|
|
|
478
522
|
<svg style="width:12px;height:12px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
479
523
|
${t("layoutGrid")}
|
|
480
524
|
</button>
|
|
525
|
+
<button class="layout-btn ${state.taskLayout === "swimlane" ? "active" : ""}" data-layout="swimlane" aria-label="${t("layoutSwimlane")}">
|
|
526
|
+
<svg style="width:12px;height:12px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/><path d="M8 4v16"/><path d="M16 4v16"/></svg>
|
|
527
|
+
${t("layoutSwimlane")}
|
|
528
|
+
</button>
|
|
481
529
|
</div>
|
|
482
530
|
</div>
|
|
483
531
|
<div class="select-group">
|
|
@@ -492,13 +540,13 @@ function taskToolbarCard(filteredCount) {
|
|
|
492
540
|
</div>
|
|
493
541
|
</div>
|
|
494
542
|
<div class="search-stats">
|
|
495
|
-
${t("showing")} <strong>${filteredCount}</strong> / ${(
|
|
543
|
+
${t("showing")} <strong>${filteredCount}</strong> / ${normalCycleTasks().length} ${t("tasks")}
|
|
496
544
|
</div>
|
|
497
545
|
</section>`;
|
|
498
546
|
}
|
|
499
547
|
|
|
500
548
|
function taskStatsCard() {
|
|
501
|
-
const allTasks =
|
|
549
|
+
const allTasks = normalCycleTasks();
|
|
502
550
|
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
503
551
|
return `<section class="sidebar-card">
|
|
504
552
|
<h3>${t("releaseHealth")}</h3>
|
|
@@ -507,13 +555,7 @@ function taskStatsCard() {
|
|
|
507
555
|
<span class="gauge-label">${t("statOverall")}</span>
|
|
508
556
|
</div>
|
|
509
557
|
<div class="stats-breakdown">
|
|
510
|
-
${
|
|
511
|
-
{ state: "in_progress", label: t("statInProgress"), colorVar: "--accent" },
|
|
512
|
-
{ state: "review", label: t("statReview"), colorVar: "--accent-2" },
|
|
513
|
-
{ state: "blocked", label: t("statBlocked"), colorVar: "--danger" },
|
|
514
|
-
{ state: "done", label: t("statDone"), colorVar: "--ok" }
|
|
515
|
-
].map(({ state, label, colorVar }) => {
|
|
516
|
-
const count = allTasks.filter(t => t.state === state).length;
|
|
558
|
+
${taskStatRows(allTasks).map(({ label, colorVar, count }) => {
|
|
517
559
|
return `<div class="stats-breakdown-row">
|
|
518
560
|
<span class="stat-label">
|
|
519
561
|
<span class="state-dot" style="background:var(${colorVar})"></span>
|
|
@@ -549,11 +591,7 @@ function taskLegendCard() {
|
|
|
549
591
|
}
|
|
550
592
|
|
|
551
593
|
function taskStatsBar() {
|
|
552
|
-
const allTasks =
|
|
553
|
-
const inProgress = allTasks.filter(t => t.state === "in_progress").length;
|
|
554
|
-
const blocked = allTasks.filter(t => t.state === "blocked").length;
|
|
555
|
-
const done = allTasks.filter(t => t.state === "done").length;
|
|
556
|
-
const review = allTasks.filter(t => t.state === "review").length;
|
|
594
|
+
const allTasks = normalCycleTasks();
|
|
557
595
|
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
558
596
|
|
|
559
597
|
return `<section class="task-stats-bar">
|
|
@@ -561,22 +599,10 @@ function taskStatsBar() {
|
|
|
561
599
|
<span class="stat-value">${allTasks.length}</span>
|
|
562
600
|
<span class="stat-label">${t("statTotal")}</span>
|
|
563
601
|
</div>
|
|
564
|
-
|
|
565
|
-
<span class="stat-value">${
|
|
566
|
-
<span class="stat-label">${
|
|
567
|
-
</div
|
|
568
|
-
<div class="stat-chip review">
|
|
569
|
-
<span class="stat-value">${review}</span>
|
|
570
|
-
<span class="stat-label">${t("statReview")}</span>
|
|
571
|
-
</div>
|
|
572
|
-
<div class="stat-chip blocked">
|
|
573
|
-
<span class="stat-value">${blocked}</span>
|
|
574
|
-
<span class="stat-label">${t("statBlocked")}</span>
|
|
575
|
-
</div>
|
|
576
|
-
<div class="stat-chip done">
|
|
577
|
-
<span class="stat-value">${done}</span>
|
|
578
|
-
<span class="stat-label">${t("statDone")}</span>
|
|
579
|
-
</div>
|
|
602
|
+
${taskStatRows(allTasks).map((row) => `<div class="stat-chip ${escapeAttr(row.className)}" style="--stat-color: var(${row.colorVar})">
|
|
603
|
+
<span class="stat-value">${row.count}</span>
|
|
604
|
+
<span class="stat-label">${escapeHtml(row.label)}</span>
|
|
605
|
+
</div>`).join("")}
|
|
580
606
|
<div class="stat-chip completion">
|
|
581
607
|
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${avgCompletion}%"></div></div>
|
|
582
608
|
<div style="text-align:right">
|
|
@@ -593,12 +619,14 @@ function taskRow(task) {
|
|
|
593
619
|
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
594
620
|
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
595
621
|
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
622
|
+
const moduleLabel = taskModuleLabel(task);
|
|
623
|
+
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
|
|
596
624
|
|
|
597
625
|
return `<article class="task-row-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
598
626
|
<div class="row-accent-bar"></div>
|
|
599
627
|
<div class="row-main">
|
|
600
628
|
<strong>${escapeHtml(task.title)}</strong>
|
|
601
|
-
<span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(
|
|
629
|
+
<span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(moduleLabel)}${lifecycle ? ` · ${escapeHtml(lifecycle)}` : ""}</span>
|
|
602
630
|
${taskCopyButton(task, "row-copy")}
|
|
603
631
|
</div>
|
|
604
632
|
<div class="row-status">${tag(task.state)}</div>
|
|
@@ -628,15 +656,16 @@ function taskIndex() {
|
|
|
628
656
|
const groupPageCount = Math.max(1, Math.ceil(orderedGroups.length / taskGroupsPerPage));
|
|
629
657
|
const groupPage = Math.min(Math.max(1, Number(state.taskGroupPage) || 1), groupPageCount);
|
|
630
658
|
const visibleGroups = orderedGroups.slice((groupPage - 1) * taskGroupsPerPage, groupPage * taskGroupsPerPage);
|
|
659
|
+
const swimlane = state.taskLayout === "swimlane";
|
|
631
660
|
|
|
632
661
|
return `<div class="tasks-grid">
|
|
633
662
|
<div class="tasks-main stack">
|
|
634
663
|
${taskStatsBar()}
|
|
635
|
-
${visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
|
|
636
|
-
|
|
664
|
+
${swimlane ? taskSwimlane(tasks) : visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
|
|
665
|
+
${swimlane ? "" : `<section class="group-pager">
|
|
637
666
|
<span>${t("showingGroups")} ${visibleGroups.length ? (groupPage - 1) * taskGroupsPerPage + 1 : 0}-${Math.min(groupPage * taskGroupsPerPage, orderedGroups.length)} / ${orderedGroups.length}</span>
|
|
638
667
|
${pager("task-groups", groupPage, groupPageCount)}
|
|
639
|
-
</section
|
|
668
|
+
</section>`}
|
|
640
669
|
</div>
|
|
641
670
|
<aside class="tasks-sidebar stack">
|
|
642
671
|
${taskToolbarCard(tasks.length)}
|
|
@@ -699,6 +728,7 @@ function taskGroup(group, tasks) {
|
|
|
699
728
|
const start = (page - 1) * taskPageSize;
|
|
700
729
|
const visibleTasks = orderedTasks.slice(start, start + taskPageSize);
|
|
701
730
|
const avgCompletion = orderedTasks.length ? clampCompletion(orderedTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / orderedTasks.length) : 0;
|
|
731
|
+
const groupContext = taskGroupContext(group, orderedTasks);
|
|
702
732
|
|
|
703
733
|
const isGrid = state.taskLayout === "grid";
|
|
704
734
|
const layoutClass = isGrid ? "task-card-grid" : "task-list";
|
|
@@ -714,8 +744,10 @@ function taskGroup(group, tasks) {
|
|
|
714
744
|
return `<section class="task-group">
|
|
715
745
|
<div class="section-head">
|
|
716
746
|
<div>
|
|
717
|
-
<
|
|
718
|
-
<
|
|
747
|
+
<p class="eyebrow">${escapeHtml(groupContext.eyebrow)}</p>
|
|
748
|
+
<h2>${escapeHtml(groupContext.title)}</h2>
|
|
749
|
+
<p class="subtle">${escapeHtml(groupContext.summary)} · ${t("showing")} ${Math.min(start + 1, orderedTasks.length)}-${Math.min(start + visibleTasks.length, orderedTasks.length)} / ${orderedTasks.length}</p>
|
|
750
|
+
${groupContext.chips.length ? `<div class="module-chip-row">${groupContext.chips.map((chip) => `<span class="module-chip">${escapeHtml(chip)}</span>`).join("")}</div>` : ""}
|
|
719
751
|
</div>
|
|
720
752
|
<div class="group-actions">
|
|
721
753
|
<div class="group-progress" aria-label="${escapeAttr(t("groupCompletion"))}">
|
|
@@ -739,6 +771,7 @@ function taskCard(task) {
|
|
|
739
771
|
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
740
772
|
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
741
773
|
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
774
|
+
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
|
|
742
775
|
|
|
743
776
|
return `<article class="task-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateColor})">
|
|
744
777
|
<div class="card-header">
|
|
@@ -752,8 +785,9 @@ function taskCard(task) {
|
|
|
752
785
|
<div class="card-meta">
|
|
753
786
|
<span class="meta-module" title="${escapeAttr(taskModuleKey(task))}">
|
|
754
787
|
<svg style="width:12px;height:12px;vertical-align:middle;margin-right:2px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
755
|
-
${escapeHtml(
|
|
788
|
+
${escapeHtml(taskModuleLabel(task))}
|
|
756
789
|
</span>
|
|
790
|
+
${lifecycle ? `<span class="meta-lifecycle" title="${escapeAttr(lifecycle)}">${escapeHtml(lifecycle)}</span>` : ""}
|
|
757
791
|
</div>
|
|
758
792
|
<div class="card-progress">
|
|
759
793
|
<div class="card-progress-track"><div class="card-progress-fill" style="width:${completion}%"></div></div>
|
|
@@ -776,7 +810,7 @@ function taskGroupLabel(group) {
|
|
|
776
810
|
if (group === "active") return t("activeCurrent");
|
|
777
811
|
if (group === "brief-ready") return t("briefReadyGroup");
|
|
778
812
|
if (group.startsWith("legacy:")) return `${t("legacyMonth")} ${group.slice("legacy:".length)}`;
|
|
779
|
-
if (group.startsWith("module:")) return
|
|
813
|
+
if (group.startsWith("module:")) return taskGroupContext(group, []).title;
|
|
780
814
|
if (group.startsWith("month:")) return `${t("legacyMonth")} ${group.slice("month:".length)}`;
|
|
781
815
|
if (group.startsWith("state:")) return `${t("columnState")} · ${label(group.slice("state:".length))}`;
|
|
782
816
|
return label(group);
|
|
@@ -784,7 +818,7 @@ function taskGroupLabel(group) {
|
|
|
784
818
|
|
|
785
819
|
function filteredTasks() {
|
|
786
820
|
const query = state.query.trim().toLowerCase();
|
|
787
|
-
return sortTasksByTime((
|
|
821
|
+
return sortTasksByTime(normalCycleTasks().filter((task) => {
|
|
788
822
|
const stateMatch = state.taskState === "all" || task.state === state.taskState;
|
|
789
823
|
if (!stateMatch) return false;
|
|
790
824
|
if (!query) return true;
|
|
@@ -796,6 +830,390 @@ function taskModuleKey(task) {
|
|
|
796
830
|
return task.module || task.inferredModule || "legacy-unclassified";
|
|
797
831
|
}
|
|
798
832
|
|
|
833
|
+
function taskModuleDisplayLabel(key) {
|
|
834
|
+
if (key === "base") return t("baseModule");
|
|
835
|
+
if (key === "legacy-unclassified") return t("unclassifiedModule");
|
|
836
|
+
return key;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function archiveView() {
|
|
840
|
+
const tasks = sortTasksByTime(archivedTasks());
|
|
841
|
+
const groups = Object.entries(groupBy(tasks, archiveBucket)).sort(([left], [right]) => left.localeCompare(right));
|
|
842
|
+
return `<main class="stack archive-view">
|
|
843
|
+
<section class="flow-panel">
|
|
844
|
+
<div class="section-head">
|
|
845
|
+
<div>
|
|
846
|
+
<p class="eyebrow">${t("archive")}</p>
|
|
847
|
+
<h2>${t("archiveView")}</h2>
|
|
848
|
+
<p class="subtle">${t("archiveSubtitle")}</p>
|
|
849
|
+
</div>
|
|
850
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="archive-summary-grid">
|
|
853
|
+
${metric(t("archivedTasks"), tasks.length)}
|
|
854
|
+
${metric(t("archiveBuckets"), groups.length)}
|
|
855
|
+
</div>
|
|
856
|
+
</section>
|
|
857
|
+
${groups.map(([bucket, bucketTasks]) => archiveGroup(bucket, bucketTasks)).join("") || emptyState(t("noArchivedTasks"))}
|
|
858
|
+
</main>`;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function archiveGroup(bucket, tasks) {
|
|
862
|
+
const orderedTasks = sortTasksByTime(tasks);
|
|
863
|
+
return `<section class="archive-group">
|
|
864
|
+
<div class="section-head">
|
|
865
|
+
<div>
|
|
866
|
+
<h2>${escapeHtml(bucket)}</h2>
|
|
867
|
+
<p class="subtle">${orderedTasks.length} ${t("tasks")}</p>
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
<div class="archive-task-list">
|
|
871
|
+
${orderedTasks.map(archiveTaskRow).join("")}
|
|
872
|
+
</div>
|
|
873
|
+
</section>`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function archiveTaskRow(task) {
|
|
877
|
+
const archiveMetadata = task.archiveMetadata || {};
|
|
878
|
+
const archivedBy = archiveMetadata?.["archived by"] || t("unknown");
|
|
879
|
+
const archivedAt = archiveMetadata?.["archived at"] || "";
|
|
880
|
+
const reviewConfirmedBy = archiveMetadata?.["review confirmed by"] || t("unknown");
|
|
881
|
+
const reviewConfirmedAt = archiveMetadata?.["review confirmed at"] || "";
|
|
882
|
+
const reviewConfirmationId = archiveMetadata?.["review confirmation id"] || "";
|
|
883
|
+
const releasePackage = archiveMetadata?.["release package"] || "";
|
|
884
|
+
const reason = task.deleteReason || archiveMetadata?.reason || "";
|
|
885
|
+
return `<article class="archive-task-row">
|
|
886
|
+
<div class="archive-task-main">
|
|
887
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}">${escapeHtml(task.title || task.id)}</a>
|
|
888
|
+
<span>${escapeHtml(task.id)}</span>
|
|
889
|
+
${reason ? `<p>${escapeHtml(reason)}</p>` : ""}
|
|
890
|
+
</div>
|
|
891
|
+
<dl class="archive-meta-grid">
|
|
892
|
+
<div><dt>${t("archivedBy")}</dt><dd>${escapeHtml(archivedBy)}</dd></div>
|
|
893
|
+
<div><dt>${t("archivedAt")}</dt><dd>${escapeHtml(archivedAt || t("unknown"))}</dd></div>
|
|
894
|
+
<div><dt>${t("reviewConfirmedBy")}</dt><dd>${escapeHtml(reviewConfirmedBy)}</dd></div>
|
|
895
|
+
<div><dt>${t("reviewConfirmedAt")}</dt><dd>${escapeHtml(reviewConfirmedAt || t("unknown"))}</dd></div>
|
|
896
|
+
${reviewConfirmationId ? `<div><dt>${t("reviewConfirmationId")}</dt><dd>${escapeHtml(reviewConfirmationId)}</dd></div>` : ""}
|
|
897
|
+
${releasePackage ? `<div><dt>${t("releasePackage")}</dt><dd>${escapeHtml(releasePackage)}</dd></div>` : ""}
|
|
898
|
+
</dl>
|
|
899
|
+
</article>`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const swimlaneStageOrder = [
|
|
903
|
+
["planned", "swimlaneStagePlanned"],
|
|
904
|
+
["in_progress", "swimlaneStageInProgress"],
|
|
905
|
+
["evidence", "swimlaneStageEvidence"],
|
|
906
|
+
["review", "swimlaneStageReview"],
|
|
907
|
+
["confirmed", "swimlaneStageConfirmed"],
|
|
908
|
+
["closeout", "swimlaneStageCloseout"],
|
|
909
|
+
["blocked", "swimlaneStageBlocked"],
|
|
910
|
+
];
|
|
911
|
+
const swimlaneCellPageSize = 10;
|
|
912
|
+
const swimlaneMiniColumnLimit = 5;
|
|
913
|
+
|
|
914
|
+
function taskSwimlaneModel(tasks) {
|
|
915
|
+
const cards = sortTasksByTime(tasks)
|
|
916
|
+
.filter((task) => taskVisibleInSwimlane(task))
|
|
917
|
+
.map((task) => {
|
|
918
|
+
const lane = taskModuleKey(task);
|
|
919
|
+
const stage = taskSwimlaneStage(task);
|
|
920
|
+
return {
|
|
921
|
+
task,
|
|
922
|
+
lane,
|
|
923
|
+
stage,
|
|
924
|
+
id: task.id,
|
|
925
|
+
title: task.title,
|
|
926
|
+
reason: taskSwimlaneReason(task),
|
|
927
|
+
};
|
|
928
|
+
});
|
|
929
|
+
const laneKeys = [...new Set(cards.map((card) => card.lane))].sort((left, right) => {
|
|
930
|
+
if (left === "legacy-unclassified") return 1;
|
|
931
|
+
if (right === "legacy-unclassified") return -1;
|
|
932
|
+
return left.localeCompare(right);
|
|
933
|
+
});
|
|
934
|
+
return {
|
|
935
|
+
stages: swimlaneStageOrder.map(([key, labelKey]) => ({ key, label: t(labelKey) })),
|
|
936
|
+
lanes: laneKeys.map((key) => ({ key, label: taskModuleDisplayLabel(key) })),
|
|
937
|
+
cards,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function taskVisibleInSwimlane(task) {
|
|
942
|
+
const stateValue = String(task.state || "");
|
|
943
|
+
const closeout = String(task.closeoutStatus || "");
|
|
944
|
+
if (["done", "closed", "finalized"].includes(stateValue)) return false;
|
|
945
|
+
if (["closed", "finalized"].includes(closeout)) return false;
|
|
946
|
+
if (clampCompletion(task.completion) >= 100 && !["review", "blocked", "reopened", "current-evidence"].includes(stateValue)) return false;
|
|
947
|
+
return ["active", "planned", "not_started", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(stateValue)
|
|
948
|
+
|| ["ready-to-confirm", "needs-material", "review-blocked"].includes(String(task.reviewQueueState || ""))
|
|
949
|
+
|| ["agent-reviewed", "confirmed", "blocked-open-findings"].includes(String(task.reviewStatus || ""));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function taskSwimlaneStage(task) {
|
|
953
|
+
const stateValue = String(task.state || "");
|
|
954
|
+
const review = String(task.reviewStatus || "");
|
|
955
|
+
const reviewQueue = String(task.reviewQueueState || "");
|
|
956
|
+
const closeout = String(task.closeoutStatus || "");
|
|
957
|
+
if (stateValue === "blocked" || review.includes("blocked") || reviewQueue.includes("blocked")) return "blocked";
|
|
958
|
+
if (review === "confirmed" && taskHasPendingLessonWork(task)) return "closeout";
|
|
959
|
+
if (review === "confirmed" && ["missing", "pending", "required", "closing"].includes(closeout)) return "closeout";
|
|
960
|
+
if (review === "confirmed") return "confirmed";
|
|
961
|
+
if (stateValue === "review" || reviewQueue === "ready-to-confirm" || (task.taskQueues || []).includes("review") || ["agent-reviewed", "in_review"].includes(review)) return "review";
|
|
962
|
+
if (["planned", "not_started"].includes(stateValue)) return "planned";
|
|
963
|
+
if (taskNeedsEvidence(task)) return "evidence";
|
|
964
|
+
if (["active", "in_progress", "reopened", "current-evidence"].includes(stateValue)) return "in_progress";
|
|
965
|
+
return "planned";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function taskNeedsEvidence(task) {
|
|
969
|
+
if (["missing", "legacy-only"].includes(String(task.visualMapStatus || ""))) return true;
|
|
970
|
+
if (task.briefSource && task.briefSource !== "standalone") return true;
|
|
971
|
+
return (task.phases || []).some((phase) => ["missing", "partial"].includes(String(phase.evidenceStatus || "")));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function taskSwimlaneReason(task) {
|
|
975
|
+
const reasons = Array.isArray(task.queueReasons) ? task.queueReasons.filter(Boolean) : [];
|
|
976
|
+
if (reasons.length) return reasons[0];
|
|
977
|
+
if (taskNeedsEvidence(task)) return t("swimlaneNeedsEvidence");
|
|
978
|
+
if (task.reviewQueueState === "ready-to-confirm") return t("swimlaneReadyToConfirm");
|
|
979
|
+
if (task.closeoutStatus === "missing") return t("swimlaneNeedsCloseout");
|
|
980
|
+
return "";
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function taskSwimlane(tasks) {
|
|
984
|
+
const model = taskSwimlaneModel(tasks);
|
|
985
|
+
if (!model.cards.length) return `<section class="task-swimlane empty-state">${escapeHtml(t("swimlaneEmpty"))}</section>`;
|
|
986
|
+
const view = taskSwimlaneHeatmapModel(model);
|
|
987
|
+
const active = taskSwimlaneActiveExpansion(view);
|
|
988
|
+
return `<section class="task-swimlane" aria-label="${escapeAttr(t("layoutSwimlane"))}">
|
|
989
|
+
<div class="swimlane-header">
|
|
990
|
+
<div>
|
|
991
|
+
<p class="eyebrow">${t("swimlaneEyebrow")}</p>
|
|
992
|
+
<h2>${t("swimlaneTitle")}</h2>
|
|
993
|
+
</div>
|
|
994
|
+
<span class="subtle">${model.cards.length} · ${t("tasks")}</span>
|
|
995
|
+
</div>
|
|
996
|
+
${taskSwimlaneHeatmap(view, active)}
|
|
997
|
+
${taskSwimlaneMobileList(view, active)}
|
|
998
|
+
${taskSwimlaneDrilldown(view, active)}
|
|
999
|
+
</section>`;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function taskSwimlaneHeatmapModel(model) {
|
|
1003
|
+
const stageTotals = Object.fromEntries(model.stages.map((stage) => [stage.key, 0]));
|
|
1004
|
+
const lanes = taskSwimlaneRenderLanes(model).map((lane) => {
|
|
1005
|
+
const stageCards = Object.fromEntries(model.stages.map((stage) => [stage.key, []]));
|
|
1006
|
+
for (const card of model.cards) {
|
|
1007
|
+
if (card.lane !== lane.key) continue;
|
|
1008
|
+
stageCards[card.stage] = stageCards[card.stage] || [];
|
|
1009
|
+
stageCards[card.stage].push(card);
|
|
1010
|
+
stageTotals[card.stage] = (stageTotals[card.stage] || 0) + 1;
|
|
1011
|
+
}
|
|
1012
|
+
const total = Object.values(stageCards).reduce((sum, cards) => sum + cards.length, 0);
|
|
1013
|
+
return { ...lane, total, stageCards };
|
|
1014
|
+
}).sort((left, right) => {
|
|
1015
|
+
if (left.key === "legacy-unclassified") return 1;
|
|
1016
|
+
if (right.key === "legacy-unclassified") return -1;
|
|
1017
|
+
const totalDiff = right.total - left.total;
|
|
1018
|
+
return totalDiff || left.label.localeCompare(right.label);
|
|
1019
|
+
});
|
|
1020
|
+
const total = model.cards.length;
|
|
1021
|
+
const columnTemplate = model.stages.map((stage) => {
|
|
1022
|
+
const count = stageTotals[stage.key] || 0;
|
|
1023
|
+
if (count === 0) return "minmax(44px, 0.36fr)";
|
|
1024
|
+
if (count <= 3) return "minmax(74px, 0.7fr)";
|
|
1025
|
+
if (count <= 7) return "minmax(88px, 1fr)";
|
|
1026
|
+
return "minmax(104px, 1.16fr)";
|
|
1027
|
+
}).join(" ");
|
|
1028
|
+
return { stages: model.stages, lanes, stageTotals, total, columnTemplate };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function taskSwimlaneRenderLanes(model) {
|
|
1032
|
+
const lanes = new Map(model.lanes.map((lane) => [lane.key, { ...lane }]));
|
|
1033
|
+
const modules = typeof dashboardModules === "function" ? dashboardModules() : [];
|
|
1034
|
+
for (const module of modules) {
|
|
1035
|
+
const key = String(module.key || "").trim();
|
|
1036
|
+
if (!key || key === "legacy-unclassified") continue;
|
|
1037
|
+
const label = key === "base" ? taskModuleDisplayLabel(key) : String(module.title || taskModuleDisplayLabel(key) || key);
|
|
1038
|
+
lanes.set(key, { ...(lanes.get(key) || { key }), key, label });
|
|
1039
|
+
}
|
|
1040
|
+
return [...lanes.values()];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function taskSwimlaneHeatmap(view, active) {
|
|
1044
|
+
const style = `--swimlane-stage-columns: ${escapeAttr(view.columnTemplate)}`;
|
|
1045
|
+
return `<div class="swimlane-heatmap" data-swimlane-heatmap="true" style="${style}" aria-label="${escapeAttr(t("swimlaneHeatmapLabel"))}">
|
|
1046
|
+
<div class="swimlane-heatmap-row swimlane-heatmap-head">
|
|
1047
|
+
<div class="swimlane-axis-label">${escapeHtml(t("swimlaneModuleColumn"))}</div>
|
|
1048
|
+
${view.stages.map((stage) => {
|
|
1049
|
+
const total = view.stageTotals[stage.key] || 0;
|
|
1050
|
+
return `<div class="swimlane-stage-header" data-swimlane-stage-total="${escapeAttr(stage.key)}" data-total="${total}">
|
|
1051
|
+
<span>${escapeHtml(stage.label)}</span>
|
|
1052
|
+
<strong>${total}</strong>
|
|
1053
|
+
</div>`;
|
|
1054
|
+
}).join("")}
|
|
1055
|
+
<div class="swimlane-total-header">${escapeHtml(t("swimlaneTotalColumn"))}</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
${view.lanes.map((lane) => taskSwimlaneHeatmapRow(lane, view, active)).join("")}
|
|
1058
|
+
</div>`;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function taskSwimlaneHeatmapRow(lane, view, active) {
|
|
1062
|
+
const laneActive = active?.mode === "lane" && active.lane === lane.key;
|
|
1063
|
+
return `<div class="swimlane-heatmap-row" data-swimlane-row="${escapeAttr(lane.key)}" data-swimlane-row-total="${lane.total}">
|
|
1064
|
+
<button class="swimlane-lane-button ${laneActive ? "active" : ""}" type="button" data-swimlane-expand="lane" data-lane="${escapeAttr(lane.key)}" aria-expanded="${laneActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel">
|
|
1065
|
+
<strong>${escapeHtml(lane.label)}</strong>
|
|
1066
|
+
<span>${lane.total}</span>
|
|
1067
|
+
</button>
|
|
1068
|
+
${view.stages.map((stage) => taskSwimlaneHeatmapCell(lane, stage, active)).join("")}
|
|
1069
|
+
<div class="swimlane-row-total"><strong>${lane.total}</strong></div>
|
|
1070
|
+
</div>`;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function taskSwimlaneHeatmapCell(lane, stage, active) {
|
|
1074
|
+
const cards = lane.stageCards[stage.key] || [];
|
|
1075
|
+
const count = cards.length;
|
|
1076
|
+
const cellActive = active?.mode === "cell" && active.lane === lane.key && active.stage === stage.key;
|
|
1077
|
+
const disabled = count === 0 ? " disabled" : "";
|
|
1078
|
+
const label = `${lane.label} · ${stage.label} · ${count} ${t("tasks")}`;
|
|
1079
|
+
return `<button class="swimlane-heat-cell heat-${taskSwimlaneHeatLevel(count)} ${cellActive ? "active" : ""}" type="button" data-swimlane-expand="cell" data-lane="${escapeAttr(lane.key)}" data-swimlane-stage="${escapeAttr(stage.key)}" data-count="${count}" aria-label="${escapeAttr(label)}" aria-expanded="${cellActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel"${disabled}>
|
|
1080
|
+
<span>${count}</span>
|
|
1081
|
+
</button>`;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function taskSwimlaneMobileList(view, active) {
|
|
1085
|
+
return `<div class="swimlane-mobile-list" aria-label="${escapeAttr(t("swimlaneHeatmapLabel"))}">
|
|
1086
|
+
${view.lanes.map((lane) => {
|
|
1087
|
+
const laneActive = active?.mode === "lane" && active.lane === lane.key;
|
|
1088
|
+
return `<button class="swimlane-mobile-module ${laneActive ? "active" : ""}" type="button" data-swimlane-expand="lane" data-lane="${escapeAttr(lane.key)}" aria-expanded="${laneActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel">
|
|
1089
|
+
<span><strong>${escapeHtml(lane.label)}</strong><small>${lane.total} · ${t("tasks")}</small></span>
|
|
1090
|
+
<span class="swimlane-mobile-stages">${view.stages.map((stage) => {
|
|
1091
|
+
const count = (lane.stageCards[stage.key] || []).length;
|
|
1092
|
+
return count ? `<em>${escapeHtml(stage.label)} ${count}</em>` : "";
|
|
1093
|
+
}).join("")}</span>
|
|
1094
|
+
</button>`;
|
|
1095
|
+
}).join("")}
|
|
1096
|
+
</div>`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function taskSwimlaneDrilldown(view, active) {
|
|
1100
|
+
if (!active) return `<div class="swimlane-drilldown-host" data-swimlane-drilldown-host="true"></div>`;
|
|
1101
|
+
const lane = view.lanes.find((candidate) => candidate.key === active.lane);
|
|
1102
|
+
if (!lane) return `<div class="swimlane-drilldown-host" data-swimlane-drilldown-host="true"></div>`;
|
|
1103
|
+
const cards = active.mode === "cell" ? (lane.stageCards[active.stage] || []) : Object.values(lane.stageCards).flat();
|
|
1104
|
+
const title = active.mode === "cell"
|
|
1105
|
+
? `${lane.label} · ${view.stages.find((stage) => stage.key === active.stage)?.label || active.stage}`
|
|
1106
|
+
: lane.label;
|
|
1107
|
+
return `<div class="swimlane-drilldown-host open" data-swimlane-drilldown-host="true">
|
|
1108
|
+
<section class="swimlane-drilldown" id="swimlane-drilldown-panel" aria-label="${escapeAttr(t("swimlaneDrilldownLabel"))}">
|
|
1109
|
+
<div class="swimlane-drilldown-head">
|
|
1110
|
+
<div>
|
|
1111
|
+
<p class="eyebrow">${escapeHtml(t("swimlaneDrilldownLabel"))}</p>
|
|
1112
|
+
<h3>${escapeHtml(title)}</h3>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div class="swimlane-drilldown-actions">
|
|
1115
|
+
<span>${cards.length} · ${t("tasks")}</span>
|
|
1116
|
+
<button type="button" data-swimlane-collapse>${escapeHtml(t("swimlaneCollapse"))}</button>
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
${active.mode === "lane" ? taskSwimlaneLaneBoard(lane, view.stages) : taskSwimlanePagedCardList(cards, active.page || 0)}
|
|
1120
|
+
</section>
|
|
1121
|
+
</div>`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function taskSwimlaneLaneBoard(lane, stages) {
|
|
1125
|
+
return `<div class="swimlane-mini-board">
|
|
1126
|
+
${stages.map((stage) => {
|
|
1127
|
+
const cards = lane.stageCards[stage.key] || [];
|
|
1128
|
+
const visibleCards = cards.slice(0, swimlaneMiniColumnLimit);
|
|
1129
|
+
const hidden = Math.max(0, cards.length - visibleCards.length);
|
|
1130
|
+
return `<div class="swimlane-mini-column">
|
|
1131
|
+
<div class="swimlane-mini-column-head"><span>${escapeHtml(stage.label)}</span><strong>${cards.length}</strong></div>
|
|
1132
|
+
<div class="swimlane-card-list">${visibleCards.map((card) => taskSwimlaneCard(card)).join("") || `<span class="swimlane-mini-empty">${escapeHtml(t("none"))}</span>`}</div>
|
|
1133
|
+
${hidden ? `<button class="swimlane-stage-drilldown" type="button" data-swimlane-expand="cell" data-swimlane-stage-drilldown="${escapeAttr(stage.key)}" data-lane="${escapeAttr(lane.key)}" data-swimlane-stage="${escapeAttr(stage.key)}">
|
|
1134
|
+
<span>+${hidden}</span>
|
|
1135
|
+
<strong>${escapeHtml(t("swimlaneViewStage"))}</strong>
|
|
1136
|
+
</button>` : ""}
|
|
1137
|
+
</div>`;
|
|
1138
|
+
}).join("")}
|
|
1139
|
+
</div>`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function taskSwimlanePagedCardList(cards, page) {
|
|
1143
|
+
const total = cards.length;
|
|
1144
|
+
const pageCount = Math.max(1, Math.ceil(total / swimlaneCellPageSize));
|
|
1145
|
+
const safePage = Math.max(0, Math.min(pageCount - 1, Number(page) || 0));
|
|
1146
|
+
const start = safePage * swimlaneCellPageSize;
|
|
1147
|
+
const end = Math.min(total, start + swimlaneCellPageSize);
|
|
1148
|
+
const visibleCards = cards.slice(start, end);
|
|
1149
|
+
return `<div class="swimlane-paged-list">
|
|
1150
|
+
<div class="swimlane-card-list">${visibleCards.map((card) => taskSwimlaneCard(card)).join("")}</div>
|
|
1151
|
+
${total > swimlaneCellPageSize ? `<div class="swimlane-pager" data-swimlane-page="${safePage}" aria-label="${escapeAttr(t("swimlanePageLabel"))}">
|
|
1152
|
+
<button type="button" data-swimlane-page-action="prev" data-page="${safePage - 1}" ${safePage <= 0 ? "disabled" : ""}>${escapeHtml(t("swimlanePrevPage"))}</button>
|
|
1153
|
+
<span>${start + 1}-${end} / ${total}</span>
|
|
1154
|
+
<button type="button" data-swimlane-page-action="next" data-page="${safePage + 1}" ${safePage >= pageCount - 1 ? "disabled" : ""}>${escapeHtml(t("swimlaneNextPage"))}</button>
|
|
1155
|
+
</div>` : ""}
|
|
1156
|
+
</div>`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function taskSwimlaneCard(card) {
|
|
1160
|
+
const task = card.task;
|
|
1161
|
+
const completion = clampCompletion(task.completion);
|
|
1162
|
+
return `<article class="swimlane-card ${escapeAttr(card.stage)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)}); --task-progress: ${completion}%">
|
|
1163
|
+
<span class="swimlane-status-dot" aria-hidden="true"></span>
|
|
1164
|
+
<strong>${escapeHtml(task.title)}</strong>
|
|
1165
|
+
<span class="swimlane-progress" aria-label="${completion}%"><i></i></span>
|
|
1166
|
+
</article>`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function taskSwimlaneHeatLevel(count) {
|
|
1170
|
+
if (count <= 0) return 0;
|
|
1171
|
+
if (count <= 3) return 1;
|
|
1172
|
+
if (count <= 7) return 2;
|
|
1173
|
+
return 3;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function taskSwimlaneActiveExpansion(view) {
|
|
1177
|
+
if (!swimlaneExpansion) return null;
|
|
1178
|
+
const lane = view.lanes.find((candidate) => candidate.key === swimlaneExpansion.lane);
|
|
1179
|
+
if (!lane) return null;
|
|
1180
|
+
if (swimlaneExpansion.mode === "cell" && !view.stages.some((stage) => stage.key === swimlaneExpansion.stage)) return null;
|
|
1181
|
+
if (swimlaneExpansion.mode !== "cell") return swimlaneExpansion;
|
|
1182
|
+
const count = (lane.stageCards[swimlaneExpansion.stage] || []).length;
|
|
1183
|
+
const pageCount = Math.max(1, Math.ceil(count / swimlaneCellPageSize));
|
|
1184
|
+
const page = Math.max(0, Math.min(pageCount - 1, Number(swimlaneExpansion.page) || 0));
|
|
1185
|
+
return { ...swimlaneExpansion, page };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let swimlaneExpansion = null;
|
|
1189
|
+
|
|
1190
|
+
if (typeof window !== "undefined" && typeof document !== "undefined" && typeof document.addEventListener === "function" && !window.__HARNESS_SWIMLANE_BOUND__) {
|
|
1191
|
+
window.__HARNESS_SWIMLANE_BOUND__ = true;
|
|
1192
|
+
document.addEventListener("click", (event) => {
|
|
1193
|
+
const collapse = event.target.closest?.("[data-swimlane-collapse]");
|
|
1194
|
+
const pager = event.target.closest?.("[data-swimlane-page-action]");
|
|
1195
|
+
const trigger = event.target.closest?.("[data-swimlane-expand]");
|
|
1196
|
+
if (!collapse && !pager && !trigger) return;
|
|
1197
|
+
event.preventDefault();
|
|
1198
|
+
if (collapse) {
|
|
1199
|
+
swimlaneExpansion = null;
|
|
1200
|
+
app();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (pager && swimlaneExpansion?.mode === "cell") {
|
|
1204
|
+
swimlaneExpansion = { ...swimlaneExpansion, page: Number(pager.dataset.page) || 0 };
|
|
1205
|
+
app();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const mode = trigger.dataset.swimlaneExpand;
|
|
1209
|
+
const lane = trigger.dataset.lane;
|
|
1210
|
+
const stage = trigger.dataset.swimlaneStage || "";
|
|
1211
|
+
const same = swimlaneExpansion?.mode === mode && swimlaneExpansion?.lane === lane && (swimlaneExpansion?.stage || "") === stage;
|
|
1212
|
+
swimlaneExpansion = same ? null : { mode, lane, stage, page: 0 };
|
|
1213
|
+
app();
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
799
1217
|
function taskDetail(route) {
|
|
800
1218
|
const taskId = route.id;
|
|
801
1219
|
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
@@ -1024,26 +1442,41 @@ function reviewActionPanel(task, { mode = "summary" } = {}) {
|
|
|
1024
1442
|
if (!isTaskInReviewQueue(task)) return "";
|
|
1025
1443
|
const blocking = task.reviewStatus === "blocked-open-findings" || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
|
|
1026
1444
|
const confirmed = task.reviewStatus === "confirmed";
|
|
1445
|
+
const readyForCloseout = taskReadyForCloseout(task);
|
|
1446
|
+
const hasLessonWork = taskHasPendingLessonWork(task);
|
|
1027
1447
|
const candidateBlocked = task.budget !== "simple" && !task.lessonCandidateDecisionComplete;
|
|
1028
1448
|
const candidateStatus = task.lessonCandidateStatus || "missing";
|
|
1029
1449
|
if (mode !== "workspace") {
|
|
1450
|
+
const summaryMessage = confirmed && hasLessonWork ? t("reviewConfirmedLessonPending") : confirmed && readyForCloseout ? t("reviewConfirmedCloseoutReady") : confirmed ? t("reviewAlreadyConfirmed") : t("reviewOpenInWorkspace");
|
|
1030
1451
|
return `<section class="side-panel review-actions">
|
|
1031
1452
|
<h3>${t("reviewActions")}</h3>
|
|
1032
|
-
<p>${escapeHtml(
|
|
1453
|
+
<p>${escapeHtml(summaryMessage)}</p>
|
|
1033
1454
|
<p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
|
|
1034
1455
|
<a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
|
|
1035
1456
|
</section>`;
|
|
1036
1457
|
}
|
|
1037
|
-
if (
|
|
1458
|
+
if (confirmed) {
|
|
1459
|
+
if (hasLessonWork) {
|
|
1460
|
+
return `<section class="side-panel review-actions">
|
|
1461
|
+
<h3>${t("reviewActions")}</h3>
|
|
1462
|
+
<p>${escapeHtml(t("reviewConfirmedLessonPending"))}</p>
|
|
1463
|
+
<p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
|
|
1464
|
+
${lessonCandidatePanel(task, { context: "detail", limit: 3 })}
|
|
1465
|
+
</section>`;
|
|
1466
|
+
}
|
|
1467
|
+
const closeoutDisabled = !readyForCloseout || !canUseWorkbenchAction("task-complete");
|
|
1038
1468
|
return `<section class="side-panel review-actions">
|
|
1039
1469
|
<h3>${t("reviewActions")}</h3>
|
|
1040
|
-
<p>${escapeHtml(t("
|
|
1470
|
+
<p>${escapeHtml(readyForCloseout ? t("reviewConfirmedCloseoutReady") : t("reviewAlreadyConfirmed"))}</p>
|
|
1471
|
+
<p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
|
|
1472
|
+
<button data-task-complete="${escapeAttr(task.id)}" ${closeoutDisabled ? "disabled" : ""}>${t("completeTaskCloseout")}</button>
|
|
1473
|
+
<div class="review-result" data-task-complete-result="${escapeAttr(task.id)}"></div>
|
|
1041
1474
|
</section>`;
|
|
1042
1475
|
}
|
|
1043
|
-
if (
|
|
1476
|
+
if (!canUseWorkbenchAction("review-complete")) {
|
|
1044
1477
|
return `<section class="side-panel review-actions">
|
|
1045
1478
|
<h3>${t("reviewActions")}</h3>
|
|
1046
|
-
<p>${escapeHtml(t("
|
|
1479
|
+
<p>${escapeHtml(t("staticReadOnlyDetail"))}</p>
|
|
1047
1480
|
</section>`;
|
|
1048
1481
|
}
|
|
1049
1482
|
const missingWalkthrough = task.budget !== "simple" && !task.walkthroughPath;
|
|
@@ -1075,6 +1508,21 @@ function taskCanBeHumanConfirmed(task) {
|
|
|
1075
1508
|
return task?.reviewQueueState === "ready-to-confirm" && Array.isArray(task?.taskQueues) && task.taskQueues.includes("review");
|
|
1076
1509
|
}
|
|
1077
1510
|
|
|
1511
|
+
function taskHasPendingLessonWork(task) {
|
|
1512
|
+
const queues = Array.isArray(task?.taskQueues) ? task.taskQueues : [];
|
|
1513
|
+
const candidates = Array.isArray(task?.lessonCandidateRows) ? task.lessonCandidateRows : [];
|
|
1514
|
+
return queues.includes("lessons")
|
|
1515
|
+
|| task?.lessonCandidateStatus === "needs-promotion"
|
|
1516
|
+
|| task?.lessonCandidatePromotionState === "queued"
|
|
1517
|
+
|| candidates.some((candidate) => ["ready-for-review", "needs-promotion"].includes(String(candidate?.status || "")));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function taskReadyForCloseout(task) {
|
|
1521
|
+
if (!task || task.reviewStatus !== "confirmed" || task.closeoutStatus === "closed") return false;
|
|
1522
|
+
if (taskHasPendingLessonWork(task)) return false;
|
|
1523
|
+
return ["no-candidate-accepted", "promoted", "rejected"].includes(String(task.lessonCandidateStatus || ""));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1078
1526
|
function evidenceList(task) {
|
|
1079
1527
|
const evidence = task.evidence || [];
|
|
1080
1528
|
return `<section class="side-panel">
|
|
@@ -1083,63 +1531,279 @@ function evidenceList(task) {
|
|
|
1083
1531
|
</section>`;
|
|
1084
1532
|
}
|
|
1085
1533
|
|
|
1534
|
+
function dashboardModules() {
|
|
1535
|
+
const structured = Array.isArray(bundle.modules) ? bundle.modules : [];
|
|
1536
|
+
if (structured.length > 0) return structured;
|
|
1537
|
+
const graphModules = (bundle.graph?.nodes || []).filter((node) => node.type === "module").map((node) => ({
|
|
1538
|
+
key: String(node.id || "").replace(/^module:/, ""),
|
|
1539
|
+
title: node.label,
|
|
1540
|
+
status: node.state,
|
|
1541
|
+
currentStep: node.currentStep,
|
|
1542
|
+
source: "graph",
|
|
1543
|
+
briefPath: node.briefPath,
|
|
1544
|
+
modulePlanPath: node.modulePlanPath,
|
|
1545
|
+
})).filter((module) => module.key);
|
|
1546
|
+
return graphModules;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function moduleDefinition(key) {
|
|
1550
|
+
return dashboardModules().find((module) => module.key === key) || null;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function taskModuleLabel(task) {
|
|
1554
|
+
const key = taskModuleKey(task);
|
|
1555
|
+
if (key === "base" || key === "legacy-unclassified") return taskModuleDisplayLabel(key);
|
|
1556
|
+
return moduleDefinition(key)?.title || key;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function taskGroupContext(group, tasks) {
|
|
1560
|
+
if (group.startsWith("module:")) {
|
|
1561
|
+
const key = group.slice("module:".length);
|
|
1562
|
+
const counts = moduleCountsForTasks(tasks);
|
|
1563
|
+
if (key === "base") {
|
|
1564
|
+
return {
|
|
1565
|
+
eyebrow: t("baseModuleEyebrow"),
|
|
1566
|
+
title: t("baseModule"),
|
|
1567
|
+
summary: `${tasks.length} ${t("tasks")} · ${counts.active} ${t("active")} · ${counts.review} ${t("statReview")} · ${counts.blocked} ${t("statBlocked")}`,
|
|
1568
|
+
chips: [`${tasks.length} ${t("tasks")}`, `${counts.risk} ${t("moduleRisks")}`],
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
if (key === "legacy-unclassified") {
|
|
1572
|
+
return {
|
|
1573
|
+
eyebrow: t("unclassifiedWarning"),
|
|
1574
|
+
title: t("unclassifiedModule"),
|
|
1575
|
+
summary: t("unclassifiedSummary").replace("{count}", String(tasks.length)),
|
|
1576
|
+
chips: [`${tasks.length} ${t("tasks")}`, `${counts.risk} ${t("moduleRisks")}`],
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
const module = moduleDefinition(key) || { key, title: key, source: "inferred" };
|
|
1580
|
+
const chips = [
|
|
1581
|
+
module.status ? `${t("columnState")}: ${label(module.status)}` : "",
|
|
1582
|
+
module.owner ? `${t("moduleOwner")}: ${module.owner}` : "",
|
|
1583
|
+
module.currentStep ? `${t("moduleCurrentStep")}: ${module.currentStep}` : "",
|
|
1584
|
+
module.dependsOn?.length ? `${t("moduleDependsOn")}: ${module.dependsOn.join(", ")}` : "",
|
|
1585
|
+
module.scope?.length ? `${t("moduleScope")}: ${module.scope.join(", ")}` : "",
|
|
1586
|
+
].filter(Boolean);
|
|
1587
|
+
return {
|
|
1588
|
+
eyebrow: module.source === "registry" ? t("registeredModule") : t("inferredModule"),
|
|
1589
|
+
title: module.title || key,
|
|
1590
|
+
summary: `${tasks.length} ${t("tasks")} · ${counts.active} ${t("active")} · ${counts.review} ${t("statReview")} · ${counts.blocked} ${t("statBlocked")}`,
|
|
1591
|
+
chips,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
eyebrow: t("groupBy"),
|
|
1596
|
+
title: taskGroupLabel(group),
|
|
1597
|
+
summary: `${tasks.length} ${t("tasks")}`,
|
|
1598
|
+
chips: [],
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function moduleCountsForTasks(tasks) {
|
|
1603
|
+
return {
|
|
1604
|
+
active: tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)).length,
|
|
1605
|
+
review: tasks.filter((task) => task.state === "review").length,
|
|
1606
|
+
blocked: tasks.filter((task) => task.state === "blocked").length,
|
|
1607
|
+
risk: tasks.filter(uiDashboardTaskHasRisk).length,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1086
1611
|
function modulesView(moduleId = "") {
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1612
|
+
const modules = modulesWithTaskFallback();
|
|
1613
|
+
const selectedKey = moduleId || state.selectedModuleKey || modules[0]?.key || "";
|
|
1614
|
+
state.selectedModuleKey = selectedKey;
|
|
1615
|
+
const selected = modules.find((module) => module.key === selectedKey) || modules[0] || null;
|
|
1616
|
+
const unclassified = normalCycleTasks().filter((task) => taskModuleKey(task) === "legacy-unclassified");
|
|
1617
|
+
return `<main class="stack module-console">
|
|
1618
|
+
${moduleRunStrip(modules, unclassified)}
|
|
1619
|
+
<section class="module-console-grid">
|
|
1620
|
+
<nav class="module-list-panel" aria-label="${escapeAttr(t("moduleView"))}">
|
|
1621
|
+
${modules.map((module) => moduleListItem(module, selected?.key === module.key)).join("") || emptyState(t("noModules"))}
|
|
1622
|
+
</nav>
|
|
1623
|
+
<article class="module-detail-panel">
|
|
1624
|
+
${selected ? moduleDetail(selected) : emptyState(t("noModules"))}
|
|
1625
|
+
</article>
|
|
1626
|
+
</section>
|
|
1627
|
+
${unclassified.length ? moduleUnclassifiedPanel(unclassified) : ""}
|
|
1628
|
+
</main>`;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function modulesWithTaskFallback() {
|
|
1632
|
+
const moduleMap = new Map(dashboardModules().map((module) => [module.key, {
|
|
1633
|
+
...module,
|
|
1634
|
+
counts: emptyUiModuleCounts(),
|
|
1635
|
+
tasks: [],
|
|
1636
|
+
}]));
|
|
1637
|
+
for (const task of normalCycleTasks()) {
|
|
1091
1638
|
const key = taskModuleKey(task);
|
|
1092
|
-
if (
|
|
1639
|
+
if (key === "legacy-unclassified") continue;
|
|
1640
|
+
if (!moduleMap.has(key)) {
|
|
1641
|
+
moduleMap.set(key, {
|
|
1642
|
+
key,
|
|
1643
|
+
title: taskModuleDisplayLabel(key),
|
|
1644
|
+
source: key === "base" ? "structure" : "inferred",
|
|
1645
|
+
status: task.classificationSource || "inferred",
|
|
1646
|
+
counts: emptyUiModuleCounts(),
|
|
1647
|
+
tasks: [],
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
const module = moduleMap.get(key);
|
|
1651
|
+
accumulateUiModuleTask(module, task);
|
|
1652
|
+
}
|
|
1653
|
+
return [...moduleMap.values()].sort((left, right) => {
|
|
1654
|
+
const leftActive = Number(left.counts?.active || 0);
|
|
1655
|
+
const rightActive = Number(right.counts?.active || 0);
|
|
1656
|
+
if (leftActive !== rightActive) return rightActive - leftActive;
|
|
1657
|
+
return left.key.localeCompare(right.key);
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function emptyUiModuleCounts() {
|
|
1662
|
+
return { total: 0, active: 0, review: 0, blocked: 0, risk: 0, missingDocs: 0 };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function accumulateUiModuleTask(module, task) {
|
|
1666
|
+
if (!module || !task) return;
|
|
1667
|
+
const stateValue = String(task.state || "unknown");
|
|
1668
|
+
if (!module.tasks.some((item) => item.id === task.id)) module.tasks.push(task);
|
|
1669
|
+
module.counts.total = (module.counts.total || 0) + 1;
|
|
1670
|
+
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(stateValue)) {
|
|
1671
|
+
module.counts.active = (module.counts.active || 0) + 1;
|
|
1672
|
+
}
|
|
1673
|
+
if (stateValue !== "active") module.counts[stateValue] = (module.counts[stateValue] || 0) + 1;
|
|
1674
|
+
if (uiDashboardTaskHasRisk(task)) {
|
|
1675
|
+
module.counts.risk = (module.counts.risk || 0) + 1;
|
|
1093
1676
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1677
|
+
if (task.briefSource && task.briefSource !== "standalone") {
|
|
1678
|
+
module.counts.missingDocs = (module.counts.missingDocs || 0) + 1;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function uiDashboardTaskHasRisk(task) {
|
|
1683
|
+
if (task.state === "blocked") return true;
|
|
1684
|
+
if (String(task.reviewStatus || "").includes("blocked")) return true;
|
|
1685
|
+
if (Array.isArray(task.materialIssues) && task.materialIssues.length > 0) return true;
|
|
1686
|
+
if (Array.isArray(task.queueReasons) && task.queueReasons.length > 0) return true;
|
|
1687
|
+
if (String(task.visualMapStatus || "") === "missing") return true;
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function moduleRunStrip(modules, unclassified) {
|
|
1692
|
+
const active = modules.filter((module) => Number(module.counts?.active || 0) > 0).length;
|
|
1693
|
+
const risk = modules.reduce((sum, module) => sum + Number(module.counts?.risk || 0), 0);
|
|
1694
|
+
const registered = modules.filter((module) => module.source === "registry").length;
|
|
1695
|
+
return `<section class="module-run-strip">
|
|
1696
|
+
${metric(t("moduleRegistered"), registered)}
|
|
1697
|
+
${metric(t("moduleActive"), active)}
|
|
1698
|
+
${metric(t("moduleRisks"), risk)}
|
|
1699
|
+
${metric(t("moduleUnclassified"), unclassified.length)}
|
|
1700
|
+
</section>`;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function moduleListItem(module, active) {
|
|
1704
|
+
const counts = module.counts || emptyUiModuleCounts();
|
|
1705
|
+
return `<a class="module-list-item ${active ? "active" : ""}" href="#/modules/${encodeURIComponent(module.key)}" data-module-select="${escapeAttr(module.key)}">
|
|
1706
|
+
<span>
|
|
1707
|
+
<strong>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</strong>
|
|
1708
|
+
<small>${escapeHtml(module.key)} · ${escapeHtml(module.source || "registry")}</small>
|
|
1709
|
+
</span>
|
|
1710
|
+
<span class="module-list-counts">
|
|
1711
|
+
<b>${Number(counts.active || 0)}</b>
|
|
1712
|
+
${tag(module.status || "planned")}
|
|
1713
|
+
</span>
|
|
1714
|
+
</a>`;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function moduleDetail(module) {
|
|
1718
|
+
const tasks = normalCycleTasks().filter((task) => taskModuleKey(task) === module.key);
|
|
1719
|
+
const activeTasks = tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state));
|
|
1720
|
+
const riskTasks = tasks.filter((task) => task.state === "blocked" || String(task.reviewStatus || "").includes("blocked") || String(task.visualMapStatus || "") === "missing");
|
|
1721
|
+
const brief = findDocument(module.briefPath || `TARGET:coding-agent-harness/planning/modules/${module.key}/brief.md`);
|
|
1722
|
+
const plan = findDocument(module.modulePlanPath || "");
|
|
1723
|
+
return `<div class="module-detail-stack">
|
|
1724
|
+
<header class="module-detail-header">
|
|
1725
|
+
<div>
|
|
1726
|
+
<p class="eyebrow">${escapeHtml(module.key === "base" ? t("baseModuleEyebrow") : module.source === "registry" ? t("registeredModule") : t("inferredModule"))}</p>
|
|
1727
|
+
<h2>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</h2>
|
|
1728
|
+
<p class="subtle">${escapeHtml(module.key)}${module.currentStep ? ` · ${escapeHtml(module.currentStep)}` : ""}</p>
|
|
1729
|
+
</div>
|
|
1730
|
+
${tag(module.status || "planned")}
|
|
1731
|
+
</header>
|
|
1732
|
+
<div class="module-chip-row">
|
|
1733
|
+
${module.owner ? `<span class="module-chip">${t("moduleOwner")}: ${escapeHtml(module.owner)}</span>` : ""}
|
|
1734
|
+
${module.branch ? `<span class="module-chip">${t("moduleBranch")}: ${escapeHtml(module.branch)}</span>` : ""}
|
|
1735
|
+
${module.dependsOn?.length ? `<span class="module-chip">${t("moduleDependsOn")}: ${escapeHtml(module.dependsOn.join(", "))}</span>` : ""}
|
|
1736
|
+
</div>
|
|
1737
|
+
<section class="module-boundary-grid">
|
|
1738
|
+
${moduleBoundaryBlock(t("moduleScope"), module.scope)}
|
|
1739
|
+
${moduleBoundaryBlock(t("moduleShared"), module.shared)}
|
|
1740
|
+
${moduleBoundaryBlock(t("moduleDependsOn"), module.dependsOn)}
|
|
1098
1741
|
</section>
|
|
1099
|
-
|
|
1742
|
+
<section class="module-work-panel">
|
|
1743
|
+
<div class="section-head">
|
|
1744
|
+
<div>
|
|
1745
|
+
<h3>${t("moduleCurrentWork")}</h3>
|
|
1746
|
+
<p class="subtle">${activeTasks.length} ${t("active")} · ${riskTasks.length} ${t("moduleRisks")}</p>
|
|
1747
|
+
</div>
|
|
1748
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
1749
|
+
</div>
|
|
1750
|
+
<div class="module-task-list">
|
|
1751
|
+
${activeTasks.slice(0, 10).map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
|
|
1752
|
+
</div>
|
|
1753
|
+
</section>
|
|
1754
|
+
${riskTasks.length ? `<section class="module-risk-panel">
|
|
1755
|
+
<h3>${t("moduleRiskPanel")}</h3>
|
|
1756
|
+
<div class="module-task-list">${riskTasks.slice(0, 8).map(moduleTaskRow).join("")}</div>
|
|
1757
|
+
</section>` : ""}
|
|
1758
|
+
<section class="module-doc-panel">
|
|
1759
|
+
<h3>${t("sourceDocuments")}</h3>
|
|
1760
|
+
<div class="module-doc-links">
|
|
1761
|
+
${moduleDocLink(t("brief"), module.briefPath, brief)}
|
|
1762
|
+
${moduleDocLink(t("taskPlan"), module.modulePlanPath, plan)}
|
|
1763
|
+
</div>
|
|
1764
|
+
<div class="markdown module-doc-preview">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
|
|
1765
|
+
</section>
|
|
1766
|
+
</div>`;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function moduleBoundaryBlock(title, values) {
|
|
1770
|
+
const items = Array.isArray(values) && values.length ? values : [t("none")];
|
|
1771
|
+
return `<div class="module-boundary-block">
|
|
1772
|
+
<strong>${escapeHtml(title)}</strong>
|
|
1773
|
+
${items.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}
|
|
1774
|
+
</div>`;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function moduleDocLink(labelText, pathValue, document) {
|
|
1778
|
+
if (!pathValue && !document) return `<span class="module-doc-link missing">${escapeHtml(labelText)} · ${t("documentMissing")}</span>`;
|
|
1779
|
+
return `<span class="module-doc-link">${escapeHtml(labelText)} · ${escapeHtml(pathValue || document.path || t("ready"))}</span>`;
|
|
1100
1780
|
}
|
|
1101
1781
|
|
|
1102
1782
|
function moduleTaskRow(task) {
|
|
1103
1783
|
const dotClass = /fail|blocked|open/i.test(task.state) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(task.state) ? "state-warn" : "state-pass";
|
|
1784
|
+
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
|
|
1104
1785
|
return `<a class="module-task-row" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}">
|
|
1105
1786
|
<div class="module-task-left">
|
|
1106
1787
|
<i class="module-task-dot ${dotClass}" title="${escapeAttr(task.state)}"></i>
|
|
1107
|
-
<span class="module-task-title">${escapeHtml(task.title)}</span>
|
|
1788
|
+
<span class="module-task-title">${escapeHtml(task.title || task.id)}</span>
|
|
1789
|
+
${lifecycle ? `<small>${escapeHtml(lifecycle)}</small>` : ""}
|
|
1108
1790
|
</div>
|
|
1109
|
-
<span class="module-task-pct">${task.completion}%</span>
|
|
1791
|
+
<span class="module-task-pct">${clampCompletion(task.completion)}%</span>
|
|
1110
1792
|
</a>`;
|
|
1111
1793
|
}
|
|
1112
1794
|
|
|
1113
|
-
function
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const brief = findDocument(module.briefPath || `TARGET:coding-agent-harness/planning/modules/${moduleKey}/brief.md`);
|
|
1124
|
-
|
|
1125
|
-
let pagerHtml = "";
|
|
1126
|
-
if (tasks.length > 8) {
|
|
1127
|
-
pagerHtml = `<div class="module-pager">
|
|
1128
|
-
<button ${currentPage <= 1 ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage - 1})">${t("prevPage")}</button>
|
|
1129
|
-
<span>${currentPage} / ${pageCount}</span>
|
|
1130
|
-
<button ${currentPage >= pageCount ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage + 1})">${t("nextPage")}</button>
|
|
1131
|
-
</div>`;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
return `<article class="module-card">
|
|
1135
|
-
<div class="card-head"><h2>${escapeHtml(module.label || moduleKey)}</h2>${tag(module.state || "unknown")}</div>
|
|
1136
|
-
<div class="markdown">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
|
|
1137
|
-
<h3>${t("moduleTasks")} · ${tasks.length}</h3>
|
|
1138
|
-
<div class="module-task-list">
|
|
1139
|
-
${visibleTasks.map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
|
|
1795
|
+
function moduleUnclassifiedPanel(tasks) {
|
|
1796
|
+
return `<section class="module-unclassified-panel">
|
|
1797
|
+
<div class="section-head">
|
|
1798
|
+
<div>
|
|
1799
|
+
<p class="eyebrow">${t("unclassifiedWarning")}</p>
|
|
1800
|
+
<h2>${t("unclassifiedModule")}</h2>
|
|
1801
|
+
<p class="subtle">${t("unclassifiedSummary").replace("{count}", String(tasks.length))}</p>
|
|
1802
|
+
</div>
|
|
1803
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
1140
1804
|
</div>
|
|
1141
|
-
|
|
1142
|
-
</
|
|
1805
|
+
<div class="module-task-list">${tasks.slice(0, 12).map(moduleTaskRow).join("")}</div>
|
|
1806
|
+
</section>`;
|
|
1143
1807
|
}
|
|
1144
1808
|
|
|
1145
1809
|
function reviewQueue() {
|
|
@@ -1150,6 +1814,10 @@ function reviewQueue() {
|
|
|
1150
1814
|
const reasonOptions = reviewReasonOptions(baseTasks);
|
|
1151
1815
|
normalizeReviewReasonFilter(reasonOptions);
|
|
1152
1816
|
const tasks = reviewFilteredTasks(baseTasks);
|
|
1817
|
+
const confirmableTasks = activeTab.id === "review" ? tasks.filter(taskCanBeHumanConfirmed) : [];
|
|
1818
|
+
syncReviewBulkSelection(confirmableTasks);
|
|
1819
|
+
if (activeTab.id === "lessons") syncLessonBulkSelection(lessonBulkActionableSelections());
|
|
1820
|
+
else syncLessonBulkSelection([]);
|
|
1153
1821
|
const pageCount = Math.max(1, Math.ceil(tasks.length / taskPageSize));
|
|
1154
1822
|
const page = Math.min(Math.max(1, Number(state.reviewQueuePage) || 1), pageCount);
|
|
1155
1823
|
const visibleTasks = tasks.slice((page - 1) * taskPageSize, page * taskPageSize);
|
|
@@ -1185,6 +1853,8 @@ function reviewQueue() {
|
|
|
1185
1853
|
</select>
|
|
1186
1854
|
</div>
|
|
1187
1855
|
</div>
|
|
1856
|
+
${activeTab.id === "review" ? reviewBulkBar(confirmableTasks) : ""}
|
|
1857
|
+
${activeTab.id === "lessons" ? lessonBulkBar() : ""}
|
|
1188
1858
|
<div class="review-queue-list-shell" tabindex="0" aria-label="${escapeAttr(activeTab.label)} ${escapeAttr(t("reviewQueue"))}">
|
|
1189
1859
|
<div class="review-queue-list">
|
|
1190
1860
|
${visibleTasks.map((task) => reviewQueueCard(task, activeTab)).join("") || emptyState(t("noQueueTasks"))}
|
|
@@ -1251,7 +1921,7 @@ function reviewSortOptions() {
|
|
|
1251
1921
|
}
|
|
1252
1922
|
|
|
1253
1923
|
function reviewQueueBaseTasks(tab) {
|
|
1254
|
-
return (
|
|
1924
|
+
return normalCycleTasks().filter((task) => taskMatchesReviewTab(task, tab));
|
|
1255
1925
|
}
|
|
1256
1926
|
|
|
1257
1927
|
function taskMatchesReviewTab(task, tab) {
|
|
@@ -1327,17 +1997,57 @@ function reviewTruthyCount(tasks, key) {
|
|
|
1327
1997
|
return tasks.filter((task) => task[key] === true).length;
|
|
1328
1998
|
}
|
|
1329
1999
|
|
|
2000
|
+
function reviewBulkSelectedIds() {
|
|
2001
|
+
return Object.entries(state.reviewBulkSelection || {})
|
|
2002
|
+
.filter(([, selected]) => selected === true)
|
|
2003
|
+
.map(([taskId]) => taskId);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function syncReviewBulkSelection(confirmableTasks) {
|
|
2007
|
+
const allowed = new Set(confirmableTasks.map((task) => task.id));
|
|
2008
|
+
for (const taskId of Object.keys(state.reviewBulkSelection || {})) {
|
|
2009
|
+
if (!allowed.has(taskId)) delete state.reviewBulkSelection[taskId];
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function reviewBulkBar(confirmableTasks) {
|
|
2014
|
+
const selectedCount = reviewBulkSelectedIds().length;
|
|
2015
|
+
const allSelected = confirmableTasks.length > 0 && confirmableTasks.every((task) => state.reviewBulkSelection?.[task.id] === true);
|
|
2016
|
+
const disabled = selectedCount === 0 || !canUseWorkbenchAction("review-complete-bulk");
|
|
2017
|
+
const result = state.reviewBulkResult ? `<span class="bulk-action-result ${state.reviewBulkResult.ok ? "success" : "failed"}">${escapeHtml(state.reviewBulkResult.message)}</span>` : "";
|
|
2018
|
+
return `<div class="bulk-action-bar review-bulk-bar">
|
|
2019
|
+
<label class="bulk-select-all">
|
|
2020
|
+
<input type="checkbox" data-review-bulk-select-all ${allSelected ? "checked" : ""} ${confirmableTasks.length ? "" : "disabled"} aria-label="${escapeAttr(t("selectAllReviewTasks"))}">
|
|
2021
|
+
<span>${t("selectAllReviewTasks")}</span>
|
|
2022
|
+
</label>
|
|
2023
|
+
<span class="bulk-selected-count">${formatMessage("reviewBulkSelected", { count: selectedCount })}</span>
|
|
2024
|
+
<button type="button" data-review-bulk-confirm ${disabled ? "disabled" : ""}>${t("reviewBulkConfirm")}</button>
|
|
2025
|
+
<button type="button" data-review-bulk-clear ${selectedCount ? "" : "disabled"}>${t("clearSelection")}</button>
|
|
2026
|
+
${result}
|
|
2027
|
+
</div>`;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1330
2030
|
function reviewQueueCard(task, tab) {
|
|
1331
2031
|
const openMaterial = (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
|
|
1332
2032
|
const reasons = task.queueReasons || [];
|
|
1333
2033
|
const canCopyRepairPrompt = tab?.repair && String(task.repairPrompt || "").trim();
|
|
1334
2034
|
const lessonActions = tab?.id === "lessons" ? lessonCandidatePanel(task, { context: "card", limit: 2 }) : "";
|
|
2035
|
+
const closeoutAction = taskReadyForCloseout(task)
|
|
2036
|
+
? `<button data-task-complete="${escapeAttr(task.id)}" ${canUseWorkbenchAction("task-complete") ? "" : "disabled"}>${t("completeTaskCloseout")}</button><span class="inline-result" data-task-complete-result="${escapeAttr(task.id)}"></span>`
|
|
2037
|
+
: "";
|
|
1335
2038
|
const displayId = task.shortId || taskFolderName(task) || task.id;
|
|
2039
|
+
const canBulkConfirm = tab?.id === "review" && taskCanBeHumanConfirmed(task);
|
|
2040
|
+
const bulkSelected = state.reviewBulkSelection?.[task.id] === true;
|
|
2041
|
+
const bulkControl = tab?.id === "review" ? `<label class="bulk-card-check">
|
|
2042
|
+
<input type="checkbox" data-review-bulk-select="${escapeAttr(task.id)}" ${canBulkConfirm ? "" : "disabled"} ${bulkSelected ? "checked" : ""} aria-label="${escapeAttr(t("selectReviewTask"))}">
|
|
2043
|
+
<span>${t("select")}</span>
|
|
2044
|
+
</label>` : "";
|
|
1336
2045
|
return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
1337
2046
|
<div class="card-header">
|
|
1338
2047
|
<span class="card-id" title="${escapeAttr(task.id)}">${escapeHtml(displayId)}</span>
|
|
1339
2048
|
${tag(task.reviewStatus || "missing")}
|
|
1340
2049
|
${reviewTaskQueues(task).map(tag).join("")}
|
|
2050
|
+
${bulkControl}
|
|
1341
2051
|
</div>
|
|
1342
2052
|
<h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
|
|
1343
2053
|
<div class="card-meta">
|
|
@@ -1348,23 +2058,39 @@ function reviewQueueCard(task, tab) {
|
|
|
1348
2058
|
<span>${t("materialsReady")}: ${task.materialsReady === true ? t("yes") : t("no")}</span>
|
|
1349
2059
|
</div>
|
|
1350
2060
|
<p class="subtle">${escapeHtml(firstUsefulLine(task.summary || task.briefText || ""))}</p>
|
|
2061
|
+
${tombstoneSummary(task)}
|
|
1351
2062
|
${reasons.length ? `<div class="review-reasons">${reasons.slice(0, 4).map(reviewReason).join("")}</div>` : ""}
|
|
1352
2063
|
${lessonActions}
|
|
1353
2064
|
<div class="review-queue-actions">
|
|
1354
2065
|
<a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
|
|
1355
2066
|
<a href="#/tasks/${encodeURIComponent(task.id)}">${t("fullView")}</a>
|
|
1356
2067
|
<button data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
|
|
2068
|
+
${closeoutAction}
|
|
1357
2069
|
${tab?.repair ? `<button data-copy-repair-prompt="${escapeAttr(task.id)}" data-repair-prompt="${escapeAttr(task.repairPrompt || "")}" ${canCopyRepairPrompt ? "" : "disabled"}>${t("copyRepairPrompt")}</button>` : ""}
|
|
1358
2070
|
</div>
|
|
1359
2071
|
</article>`;
|
|
1360
2072
|
}
|
|
1361
2073
|
|
|
2074
|
+
function tombstoneSummary(task) {
|
|
2075
|
+
const deletionState = String(task?.deletionState || "active");
|
|
2076
|
+
if (deletionState === "active") return "";
|
|
2077
|
+
const reason = String(task?.deleteReason || "").trim();
|
|
2078
|
+
const supersededBy = String(task?.supersededBy || "").trim();
|
|
2079
|
+
return `<div class="review-tombstone-summary">
|
|
2080
|
+
<span>${tag(deletionState)}</span>
|
|
2081
|
+
${reason ? `<span>${t("reason")}: ${escapeHtml(reason)}</span>` : ""}
|
|
2082
|
+
${supersededBy ? `<a href="#/tasks/${encodeURIComponent(supersededBy)}">${escapeHtml(supersededBy)}</a>` : ""}
|
|
2083
|
+
</div>`;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1362
2086
|
function lessonCandidatePanel(task, { context = "detail", limit = 0 } = {}) {
|
|
1363
2087
|
const candidates = (task.lessonCandidateRows || []).filter((candidate) => ["ready-for-review", "needs-promotion"].includes(candidate.status));
|
|
1364
2088
|
if (!candidates.length) return "";
|
|
1365
2089
|
const visibleCandidates = limit > 0 ? candidates.slice(0, limit) : candidates;
|
|
1366
2090
|
const hiddenCount = Math.max(0, candidates.length - visibleCandidates.length);
|
|
1367
2091
|
const staticNote = canUseWorkbenchAction("lesson-sedimentation-task") ? "" : `<p class="lesson-action-note">${escapeHtml(t("lessonWorkbenchRequired"))}</p>`;
|
|
2092
|
+
syncLessonBulkSelection(lessonBulkActionableSelections());
|
|
2093
|
+
const bulkBar = context === "card" ? "" : lessonBulkBar();
|
|
1368
2094
|
return `<section class="lesson-candidate-panel ${context === "card" ? "compact" : ""}">
|
|
1369
2095
|
<div class="lesson-candidate-panel-head">
|
|
1370
2096
|
<div>
|
|
@@ -1374,6 +2100,7 @@ function lessonCandidatePanel(task, { context = "detail", limit = 0 } = {}) {
|
|
|
1374
2100
|
<span class="tag">${visibleCandidates.length}/${candidates.length}</span>
|
|
1375
2101
|
</div>
|
|
1376
2102
|
${staticNote}
|
|
2103
|
+
${bulkBar}
|
|
1377
2104
|
<div class="lesson-candidate-actions">
|
|
1378
2105
|
${visibleCandidates.map((candidate) => lessonCandidateAction(task, candidate)).join("")}
|
|
1379
2106
|
</div>
|
|
@@ -1385,6 +2112,9 @@ function lessonCandidateAction(task, candidate) {
|
|
|
1385
2112
|
const followUp = String(candidate.followUpTask || "").trim();
|
|
1386
2113
|
const hasFollowUp = followUp && !/^pending$/i.test(followUp);
|
|
1387
2114
|
const prompt = lessonSedimentationPrompt(task, candidate);
|
|
2115
|
+
const selectionKey = lessonBulkSelectionKey(task.id, candidate.id);
|
|
2116
|
+
const canBulkCreate = canUseWorkbenchAction("lesson-sedimentation-bulk") && !hasFollowUp;
|
|
2117
|
+
const selected = state.lessonBulkSelection?.[selectionKey] === true;
|
|
1388
2118
|
return `<div class="lesson-candidate-action">
|
|
1389
2119
|
<div class="lesson-candidate-main">
|
|
1390
2120
|
<strong>${escapeHtml(candidate.id)}</strong>
|
|
@@ -1393,6 +2123,10 @@ function lessonCandidateAction(task, candidate) {
|
|
|
1393
2123
|
</div>
|
|
1394
2124
|
<span class="review-result" data-lesson-result="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}"></span>
|
|
1395
2125
|
<div class="lesson-candidate-command-row">
|
|
2126
|
+
<label class="bulk-card-check lesson-bulk-check">
|
|
2127
|
+
<input type="checkbox" data-lesson-bulk-select="${escapeAttr(selectionKey)}" ${canBulkCreate ? "" : "disabled"} ${selected ? "checked" : ""} aria-label="${escapeAttr(t("selectLessonCandidate"))}">
|
|
2128
|
+
<span>${t("select")}</span>
|
|
2129
|
+
</label>
|
|
1396
2130
|
${hasFollowUp ? `<a href="#/tasks/${encodeURIComponent(followUp)}">${t("openFollowUpTask")}</a>` : ""}
|
|
1397
2131
|
<button data-copy-lesson-prompt="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}" data-lesson-prompt="${escapeAttr(prompt)}">${t("copyLessonPrompt")}</button>
|
|
1398
2132
|
<button data-create-lesson-sedimentation="${escapeAttr(task.id)}" data-candidate-id="${escapeAttr(candidate.id)}" ${canUseWorkbenchAction("lesson-sedimentation-task") && !hasFollowUp ? "" : "disabled"}>${t("createLessonTask")}</button>
|
|
@@ -1400,6 +2134,62 @@ function lessonCandidateAction(task, candidate) {
|
|
|
1400
2134
|
</div>`;
|
|
1401
2135
|
}
|
|
1402
2136
|
|
|
2137
|
+
function lessonBulkSelectionKey(taskId, candidateId) {
|
|
2138
|
+
return `${taskId}::${candidateId}`;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function parseLessonBulkSelectionKey(key) {
|
|
2142
|
+
const separator = String(key || "").lastIndexOf("::");
|
|
2143
|
+
if (separator < 0) return null;
|
|
2144
|
+
const taskId = key.slice(0, separator);
|
|
2145
|
+
const candidateId = key.slice(separator + 2);
|
|
2146
|
+
if (!taskId || !candidateId) return null;
|
|
2147
|
+
return { taskId, candidateId };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function lessonBulkSelectedSelections() {
|
|
2151
|
+
return Object.entries(state.lessonBulkSelection || {})
|
|
2152
|
+
.filter(([, selected]) => selected === true)
|
|
2153
|
+
.map(([key]) => parseLessonBulkSelectionKey(key))
|
|
2154
|
+
.filter(Boolean);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function lessonBulkActionableSelections() {
|
|
2158
|
+
return (bundle.status?.tasks || []).flatMap((task) => (task.lessonCandidateRows || [])
|
|
2159
|
+
.filter((candidate) => ["ready-for-review", "needs-promotion"].includes(candidate.status))
|
|
2160
|
+
.filter((candidate) => {
|
|
2161
|
+
const followUp = String(candidate.followUpTask || "").trim();
|
|
2162
|
+
return !followUp || /^pending$/i.test(followUp);
|
|
2163
|
+
})
|
|
2164
|
+
.map((candidate) => ({ taskId: task.id, candidateId: candidate.id })));
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function syncLessonBulkSelection(actionableSelections) {
|
|
2168
|
+
const allowed = new Set(actionableSelections.map((selection) => lessonBulkSelectionKey(selection.taskId, selection.candidateId)));
|
|
2169
|
+
for (const key of Object.keys(state.lessonBulkSelection || {})) {
|
|
2170
|
+
if (!allowed.has(key)) delete state.lessonBulkSelection[key];
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function lessonBulkBar() {
|
|
2175
|
+
const actionableSelections = lessonBulkActionableSelections();
|
|
2176
|
+
syncLessonBulkSelection(actionableSelections);
|
|
2177
|
+
const selectedCount = lessonBulkSelectedSelections().length;
|
|
2178
|
+
const allSelected = actionableSelections.length > 0 && actionableSelections.every((selection) => state.lessonBulkSelection?.[lessonBulkSelectionKey(selection.taskId, selection.candidateId)] === true);
|
|
2179
|
+
const disabled = selectedCount === 0 || !canUseWorkbenchAction("lesson-sedimentation-bulk");
|
|
2180
|
+
const result = state.lessonBulkResult ? `<span class="bulk-action-result ${state.lessonBulkResult.ok ? "success" : "failed"}">${escapeHtml(state.lessonBulkResult.message)}</span>` : "";
|
|
2181
|
+
return `<div class="bulk-action-bar lesson-bulk-bar">
|
|
2182
|
+
<label class="bulk-select-all">
|
|
2183
|
+
<input type="checkbox" data-lesson-bulk-select-all ${allSelected ? "checked" : ""} ${actionableSelections.length ? "" : "disabled"} aria-label="${escapeAttr(t("selectAllLessonCandidates"))}">
|
|
2184
|
+
<span>${t("selectAllLessonCandidates")}</span>
|
|
2185
|
+
</label>
|
|
2186
|
+
<span class="bulk-selected-count">${formatMessage("lessonBulkSelected", { count: selectedCount })}</span>
|
|
2187
|
+
<button type="button" data-lesson-bulk-create ${disabled ? "disabled" : ""}>${t("lessonBulkCreate")}</button>
|
|
2188
|
+
<button type="button" data-lesson-bulk-clear ${selectedCount ? "" : "disabled"}>${t("clearSelection")}</button>
|
|
2189
|
+
${result}
|
|
2190
|
+
</div>`;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1403
2193
|
function lessonSedimentationPrompt(task, candidate) {
|
|
1404
2194
|
return [
|
|
1405
2195
|
"You are executing a lesson sedimentation follow-up task.",
|
|
@@ -2120,12 +2910,26 @@ window.setModulePage = function(moduleKey, page) {
|
|
|
2120
2910
|
app();
|
|
2121
2911
|
};
|
|
2122
2912
|
|
|
2913
|
+
function rerenderPreservingFieldFocus(field, selector) {
|
|
2914
|
+
const shouldRestore = document.activeElement === field;
|
|
2915
|
+
const selectionStart = typeof field.selectionStart === "number" ? field.selectionStart : null;
|
|
2916
|
+
const selectionEnd = typeof field.selectionEnd === "number" ? field.selectionEnd : selectionStart;
|
|
2917
|
+
app();
|
|
2918
|
+
if (!shouldRestore) return;
|
|
2919
|
+
const nextField = document.querySelector(selector);
|
|
2920
|
+
if (!nextField || typeof nextField.focus !== "function") return;
|
|
2921
|
+
nextField.focus({ preventScroll: true });
|
|
2922
|
+
if (typeof nextField.setSelectionRange === "function" && selectionStart !== null) {
|
|
2923
|
+
nextField.setSelectionRange(selectionStart, selectionEnd);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2123
2927
|
function bind() {
|
|
2124
2928
|
document.querySelectorAll("[data-search]").forEach((input) => input.addEventListener("input", () => {
|
|
2125
2929
|
state.query = input.value;
|
|
2126
2930
|
state.taskPageByGroup = {};
|
|
2127
2931
|
state.taskGroupPage = 1;
|
|
2128
|
-
|
|
2932
|
+
rerenderPreservingFieldFocus(input, "[data-search]");
|
|
2129
2933
|
}));
|
|
2130
2934
|
document.querySelectorAll("[data-state-filter]").forEach((select) => select.addEventListener("change", () => {
|
|
2131
2935
|
state.taskState = select.value;
|
|
@@ -2135,6 +2939,7 @@ function bind() {
|
|
|
2135
2939
|
}));
|
|
2136
2940
|
document.querySelectorAll("[data-group-mode]").forEach((select) => select.addEventListener("change", () => {
|
|
2137
2941
|
state.taskGroupMode = select.value;
|
|
2942
|
+
localStorage.setItem("harness.taskGroupMode", state.taskGroupMode);
|
|
2138
2943
|
state.taskPageByGroup = {};
|
|
2139
2944
|
state.taskGroupPage = 1;
|
|
2140
2945
|
app();
|
|
@@ -2167,7 +2972,7 @@ function bind() {
|
|
|
2167
2972
|
}));
|
|
2168
2973
|
document.querySelectorAll("[data-preset-search]").forEach((input) => input.addEventListener("input", () => {
|
|
2169
2974
|
state.presetQuery = input.value;
|
|
2170
|
-
|
|
2975
|
+
rerenderPreservingFieldFocus(input, "[data-preset-search]");
|
|
2171
2976
|
}));
|
|
2172
2977
|
document.querySelectorAll("[data-preset-source-filter]").forEach((button) => button.addEventListener("click", () => {
|
|
2173
2978
|
state.presetSourceFilter = button.dataset.presetSourceFilter || "all";
|
|
@@ -2230,6 +3035,9 @@ function bind() {
|
|
|
2230
3035
|
document.querySelectorAll("[data-review-queue-tab]").forEach((button) => button.addEventListener("click", () => {
|
|
2231
3036
|
state.reviewQueueTab = button.dataset.reviewQueueTab || "review";
|
|
2232
3037
|
state.reviewQueuePage = 1;
|
|
3038
|
+
state.reviewBulkSelection = {};
|
|
3039
|
+
state.reviewBulkResult = null;
|
|
3040
|
+
state.lessonBulkResult = null;
|
|
2233
3041
|
app();
|
|
2234
3042
|
}));
|
|
2235
3043
|
document.querySelectorAll("[data-review-reason-filter]").forEach((select) => select.addEventListener("change", () => {
|
|
@@ -2242,6 +3050,51 @@ function bind() {
|
|
|
2242
3050
|
state.reviewQueuePage = 1;
|
|
2243
3051
|
app();
|
|
2244
3052
|
}));
|
|
3053
|
+
document.querySelectorAll("[data-review-bulk-select]").forEach((input) => input.addEventListener("change", () => {
|
|
3054
|
+
state.reviewBulkSelection = state.reviewBulkSelection || {};
|
|
3055
|
+
state.reviewBulkSelection[input.dataset.reviewBulkSelect || ""] = input.checked;
|
|
3056
|
+
state.reviewBulkResult = null;
|
|
3057
|
+
app();
|
|
3058
|
+
}));
|
|
3059
|
+
document.querySelectorAll("[data-review-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {
|
|
3060
|
+
const activeTab = reviewQueueTabs().find((tab) => tab.id === state.reviewQueueTab) || reviewQueueTabs()[0];
|
|
3061
|
+
const tasks = activeTab.id === "review" ? reviewFilteredTasks(reviewQueueBaseTasks(activeTab)).filter(taskCanBeHumanConfirmed) : [];
|
|
3062
|
+
state.reviewBulkSelection = state.reviewBulkSelection || {};
|
|
3063
|
+
tasks.forEach((task) => {
|
|
3064
|
+
if (input.checked) state.reviewBulkSelection[task.id] = true;
|
|
3065
|
+
else delete state.reviewBulkSelection[task.id];
|
|
3066
|
+
});
|
|
3067
|
+
state.reviewBulkResult = null;
|
|
3068
|
+
app();
|
|
3069
|
+
}));
|
|
3070
|
+
document.querySelectorAll("[data-review-bulk-clear]").forEach((button) => button.addEventListener("click", () => {
|
|
3071
|
+
state.reviewBulkSelection = {};
|
|
3072
|
+
state.reviewBulkResult = null;
|
|
3073
|
+
app();
|
|
3074
|
+
}));
|
|
3075
|
+
document.querySelectorAll("[data-review-bulk-confirm]").forEach((button) => button.addEventListener("click", () => confirmSelectedReviewsFromDashboard(button)));
|
|
3076
|
+
document.querySelectorAll("[data-lesson-bulk-select]").forEach((input) => input.addEventListener("change", () => {
|
|
3077
|
+
state.lessonBulkSelection = state.lessonBulkSelection || {};
|
|
3078
|
+
state.lessonBulkSelection[input.dataset.lessonBulkSelect || ""] = input.checked;
|
|
3079
|
+
state.lessonBulkResult = null;
|
|
3080
|
+
app();
|
|
3081
|
+
}));
|
|
3082
|
+
document.querySelectorAll("[data-lesson-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {
|
|
3083
|
+
state.lessonBulkSelection = state.lessonBulkSelection || {};
|
|
3084
|
+
lessonBulkActionableSelections().forEach((selection) => {
|
|
3085
|
+
const key = lessonBulkSelectionKey(selection.taskId, selection.candidateId);
|
|
3086
|
+
if (input.checked) state.lessonBulkSelection[key] = true;
|
|
3087
|
+
else delete state.lessonBulkSelection[key];
|
|
3088
|
+
});
|
|
3089
|
+
state.lessonBulkResult = null;
|
|
3090
|
+
app();
|
|
3091
|
+
}));
|
|
3092
|
+
document.querySelectorAll("[data-lesson-bulk-clear]").forEach((button) => button.addEventListener("click", () => {
|
|
3093
|
+
state.lessonBulkSelection = {};
|
|
3094
|
+
state.lessonBulkResult = null;
|
|
3095
|
+
app();
|
|
3096
|
+
}));
|
|
3097
|
+
document.querySelectorAll("[data-lesson-bulk-create]").forEach((button) => button.addEventListener("click", () => createSelectedLessonSedimentationFromDashboard(button)));
|
|
2245
3098
|
document.querySelectorAll("[data-page-kind]").forEach((button) => button.addEventListener("click", () => {
|
|
2246
3099
|
const page = Math.max(1, Number(button.dataset.page) || 1);
|
|
2247
3100
|
if (button.dataset.pageKind === "warning") state.warningPage = page;
|
|
@@ -2282,6 +3135,7 @@ function bind() {
|
|
|
2282
3135
|
openLessonDrawer(lessonId);
|
|
2283
3136
|
}));
|
|
2284
3137
|
document.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
3138
|
+
document.querySelectorAll("[data-task-complete]").forEach((button) => button.addEventListener("click", () => completeTaskFromDashboard(button.dataset.taskComplete)));
|
|
2285
3139
|
const overlay = document.getElementById("drawer-overlay");
|
|
2286
3140
|
if (overlay) overlay.addEventListener("click", closeDrawer);
|
|
2287
3141
|
}
|
|
@@ -2356,6 +3210,90 @@ async function completeReviewFromDashboard(taskId) {
|
|
|
2356
3210
|
}
|
|
2357
3211
|
}
|
|
2358
3212
|
|
|
3213
|
+
async function completeTaskFromDashboard(taskId) {
|
|
3214
|
+
const result = document.querySelector(`[data-task-complete-result="${CSS.escape(taskId)}"]`);
|
|
3215
|
+
if (result) result.textContent = t("taskCloseoutSubmitting");
|
|
3216
|
+
try {
|
|
3217
|
+
const response = await fetch("/api/tasks/task-complete", {
|
|
3218
|
+
method: "POST",
|
|
3219
|
+
headers: {
|
|
3220
|
+
"content-type": "application/json",
|
|
3221
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
3222
|
+
},
|
|
3223
|
+
body: JSON.stringify({
|
|
3224
|
+
taskId,
|
|
3225
|
+
message: "closed from dashboard workbench",
|
|
3226
|
+
evidence: "dashboard:task-complete",
|
|
3227
|
+
}),
|
|
3228
|
+
});
|
|
3229
|
+
const payload = await response.json();
|
|
3230
|
+
if (!response.ok) throw new Error(payload.error || t("taskCloseoutFailed"));
|
|
3231
|
+
if (result) result.textContent = t("taskCloseoutSuccess");
|
|
3232
|
+
setTimeout(() => window.location.reload(), 500);
|
|
3233
|
+
} catch (error) {
|
|
3234
|
+
if (result) result.textContent = `${t("taskCloseoutFailed")}: ${error.message}`;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
function dashboardActionErrorDetail(error, fallback) {
|
|
3239
|
+
const direct = error?.error || error?.message;
|
|
3240
|
+
if (direct) return direct;
|
|
3241
|
+
const failedResults = Array.isArray(error?.results) ? error.results.filter((result) => result?.ok === false) : [];
|
|
3242
|
+
if (failedResults.length > 0) {
|
|
3243
|
+
const reasons = [];
|
|
3244
|
+
for (const result of failedResults) {
|
|
3245
|
+
const reason = result?.error || result?.message;
|
|
3246
|
+
if (reason && !reasons.includes(reason)) reasons.push(reason);
|
|
3247
|
+
}
|
|
3248
|
+
if (reasons.length > 0) {
|
|
3249
|
+
return formatMessage("bulkActionFailedWithReason", {
|
|
3250
|
+
failed: error?.failed || failedResults.length,
|
|
3251
|
+
reason: reasons.slice(0, 3).join("; "),
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
return formatMessage("bulkActionFailedSummary", { failed: error?.failed || failedResults.length });
|
|
3255
|
+
}
|
|
3256
|
+
return String(error || fallback);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
async function confirmSelectedReviewsFromDashboard(button) {
|
|
3260
|
+
const taskIds = reviewBulkSelectedIds();
|
|
3261
|
+
if (!taskIds.length) {
|
|
3262
|
+
state.reviewBulkResult = { ok: false, message: t("reviewBulkNone") };
|
|
3263
|
+
app();
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
button.disabled = true;
|
|
3267
|
+
state.reviewBulkResult = { ok: true, message: t("reviewBulkSubmitting") };
|
|
3268
|
+
app();
|
|
3269
|
+
try {
|
|
3270
|
+
const response = await fetch("/api/tasks/review-complete-bulk", {
|
|
3271
|
+
method: "POST",
|
|
3272
|
+
headers: {
|
|
3273
|
+
"content-type": "application/json",
|
|
3274
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
3275
|
+
},
|
|
3276
|
+
body: JSON.stringify({
|
|
3277
|
+
taskIds,
|
|
3278
|
+
reviewer: "Human Reviewer",
|
|
3279
|
+
message: "bulk confirmed from dashboard workbench",
|
|
3280
|
+
}),
|
|
3281
|
+
});
|
|
3282
|
+
const payload = await response.json();
|
|
3283
|
+
if (!response.ok) throw payload;
|
|
3284
|
+
state.reviewBulkSelection = {};
|
|
3285
|
+
state.reviewBulkResult = {
|
|
3286
|
+
ok: payload.failed === 0,
|
|
3287
|
+
message: payload.failed ? formatMessage("reviewBulkPartial", { confirmed: payload.confirmed || 0, failed: payload.failed || 0 }) : formatMessage("reviewBulkSuccess", { confirmed: payload.confirmed || 0 }),
|
|
3288
|
+
};
|
|
3289
|
+
app();
|
|
3290
|
+
if ((payload.confirmed || 0) > 0) setTimeout(() => window.location.reload(), 1500);
|
|
3291
|
+
} catch (error) {
|
|
3292
|
+
state.reviewBulkResult = { ok: false, message: `${t("reviewCompleteFailed")}: ${dashboardActionErrorDetail(error, t("reviewCompleteFailed"))}` };
|
|
3293
|
+
app();
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
|
|
2359
3297
|
async function runPresetAction(action, body) {
|
|
2360
3298
|
state.presetActionResult = { ok: true, title: t("presetActionRunning"), message: action };
|
|
2361
3299
|
app();
|
|
@@ -2454,6 +3392,7 @@ function openDrawer(taskId) {
|
|
|
2454
3392
|
bindRepairPromptButtons(drawer);
|
|
2455
3393
|
bindLessonSedimentationButtons(drawer);
|
|
2456
3394
|
drawer.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
3395
|
+
drawer.querySelectorAll("[data-task-complete]").forEach((button) => button.addEventListener("click", () => completeTaskFromDashboard(button.dataset.taskComplete)));
|
|
2457
3396
|
}
|
|
2458
3397
|
|
|
2459
3398
|
function bindCopyTaskNameButtons(root) {
|
|
@@ -2572,6 +3511,40 @@ async function createLessonSedimentationFromDashboard(button) {
|
|
|
2572
3511
|
}
|
|
2573
3512
|
}
|
|
2574
3513
|
|
|
3514
|
+
async function createSelectedLessonSedimentationFromDashboard(button) {
|
|
3515
|
+
const selections = lessonBulkSelectedSelections();
|
|
3516
|
+
if (!selections.length) {
|
|
3517
|
+
state.lessonBulkResult = { ok: false, message: t("lessonBulkNone") };
|
|
3518
|
+
app();
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
button.disabled = true;
|
|
3522
|
+
state.lessonBulkResult = { ok: true, message: t("lessonBulkSubmitting") };
|
|
3523
|
+
app();
|
|
3524
|
+
try {
|
|
3525
|
+
const response = await fetch("/api/tasks/lesson-sedimentation-bulk", {
|
|
3526
|
+
method: "POST",
|
|
3527
|
+
headers: {
|
|
3528
|
+
"content-type": "application/json",
|
|
3529
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
3530
|
+
},
|
|
3531
|
+
body: JSON.stringify({ selections }),
|
|
3532
|
+
});
|
|
3533
|
+
const payload = await response.json();
|
|
3534
|
+
if (!response.ok) throw payload;
|
|
3535
|
+
state.lessonBulkSelection = {};
|
|
3536
|
+
state.lessonBulkResult = {
|
|
3537
|
+
ok: payload.failed === 0,
|
|
3538
|
+
message: payload.failed ? formatMessage("lessonBulkPartial", { created: payload.created || 0, failed: payload.failed || 0 }) : formatMessage("lessonBulkSuccess", { candidates: payload.candidates || selections.length }),
|
|
3539
|
+
};
|
|
3540
|
+
app();
|
|
3541
|
+
if ((payload.created || 0) > 0) setTimeout(() => window.location.reload(), 1500);
|
|
3542
|
+
} catch (error) {
|
|
3543
|
+
state.lessonBulkResult = { ok: false, message: `${t("lessonTaskCreateFailed")}: ${error?.error || error?.message || String(error)}` };
|
|
3544
|
+
app();
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
|
|
2575
3548
|
function lessonSedimentationSuccess(payload) {
|
|
2576
3549
|
const followUp = payload?.followUpTask || {};
|
|
2577
3550
|
const prompt = payload?.prompt || "";
|