Commit 7073e204a074012f8bf55e4753644f6d301afc2e

Authored by graphlemon
1 parent 70f23ac9

graphlemon: auto-commit uncommitted changes for 6299a5c5-5359-4017-af00-19ab432484c6

frontend/app.js
... ... @@ -97,6 +97,13 @@ const routes = {
97 97 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
98 98 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
99 99 const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
  100 +// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and
  101 +// v_task_detail's org-strip nodes route here. `:org_node` is restricted to
  102 +// the three real values on purpose -- anything else (typo, stray path
  103 +// segment) falls through to the "Not found" fallback in render() instead of
  104 +// reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would
  105 +// otherwise need its own defensive checks further down.
  106 +const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/;
100 107  
101 108 let currentRouteCleanup = null;
102 109 function registerRouteCleanup(fn) {
... ... @@ -114,9 +121,15 @@ function render() {
114 121 let routeArgs = [];
115 122 if (!route) {
116 123 const taskMatch = hash.match(TASK_DETAIL_RE);
  124 + const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE);
117 125 const skillMatch = hash.match(SKILL_AUDIT_RE);
118 126 const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
119   - if (taskMatch) {
  127 + if (orgNodeMatch) {
  128 + // Reuses v_task_detail itself (same render/mount) with the org_node
  129 + // carried through as a second arg -- no separate view/route handler.
  130 + route = { render: renderTaskDetail, mount: mountTaskDetail };
  131 + routeArgs = [orgNodeMatch[1], orgNodeMatch[2]];
  132 + } else if (taskMatch) {
120 133 route = { render: renderTaskDetail, mount: mountTaskDetail };
121 134 routeArgs = [taskMatch[1]];
122 135 } else if (skillMatch) {
... ... @@ -537,6 +550,120 @@ function mountBugFeedback() {
537 550  
538 551 const ORG_NODE_ORDER = ["planner", "coder", "validator"];
539 552  
  553 +// ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by
  554 +// v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below
  555 +// -- both mark each `.org-node` with `data-org-node`, both route through
  556 +// `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for
  557 +// that org_node the same way (C6: plain array filtering, no new backend
  558 +// query params). DAG/log/artifact display itself stays entirely inside
  559 +// renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser
  560 +// (C3-C5) -- this block only does the "接线": which run is "latest", and
  561 +// where on the already-rendered page to scroll/highlight.
  562 +
  563 +function getLatestRunForOrgNode(task, orgNode) {
  564 + const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode);
  565 + return runs.length ? runs[runs.length - 1] : null;
  566 +}
  567 +
  568 +// v_dashboard's org-strip: role + hint text, only ever links out to
  569 +// v_task_detail (a genuine page change, so plain hash assignment is fine).
  570 +function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
  571 + return ORG_NODE_ORDER.map((node, i) => {
  572 + const isActive = activeOrgNode === node;
  573 + const runStatus = runStatusByOrgNode[node];
  574 + const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
  575 + const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
  576 + return '<div class="org-node' + (isActive ? " active" : "") + '" data-org-node="' + node + '"><div class="role">'
  577 + + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow;
  578 + }).join("");
  579 +}
  580 +
  581 +// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint
  582 +// split), `activeSet` already carries different semantics than the
  583 +// dashboard's single activeOrgNode -- kept as its own function rather than
  584 +// forcing the two call sites to share one shape.
  585 +function renderTaskDetailOrgStripHtml(activeSet) {
  586 + return ORG_NODE_ORDER.map((node, i) => {
  587 + const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
  588 + return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '" data-org-node="' + node + '">' + node + "</div>" + arrow;
  589 + }).join("");
  590 +}
  591 +
  592 +function mountOrgStripClicks(container, taskId) {
  593 + if (!taskId) return;
  594 + for (const el of container.querySelectorAll(".org-node[data-org-node]")) {
  595 + el.addEventListener("click", () => {
  596 + window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode;
  597 + });
  598 + }
  599 +}
  600 +
  601 +// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the
  602 +// artifact list's own markup pattern (li[data-artifact-id], same as
  603 +// renderArtifactSummaryList) filtered down to this run's own artifacts --
  604 +// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact
  605 +// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here
  606 +// -- they're already rendered once, further down this same page, by the
  607 +// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function
  608 +// only describes which run that is and lists its artifacts.
  609 +function renderOrgComboPanelContent(task, orgNode) {
  610 + const run = getLatestRunForOrgNode(task, orgNode);
  611 + if (!run) {
  612 + return '<div class="hint">' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)</div>";
  613 + }
  614 + const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0;
  615 + const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id);
  616 + const artifactsHtml = artifacts.length ? (
  617 + '<ul class="simple-list">' + artifacts.map((a) =>
  618 + '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.file_path) + "</span><span>"
  619 + + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "</span></li>"
  620 + ).join("") + "</ul>"
  621 + ) : '<div class="hint">暂无 Artifact</div>';
  622 + return (
  623 + '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")"
  624 + + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "</div>"
  625 + + '<h3 style="margin:14px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;">该 run 产出的 Artifact</h3>'
  626 + + artifactsHtml
  627 + );
  628 +}
  629 +
  630 +function wireOrgComboArtifactClicks(scopeEl) {
  631 + const combo = scopeEl.querySelector("#org_combo_content");
  632 + if (!combo) return;
  633 + for (const li of combo.querySelectorAll("li[data-artifact-id]")) {
  634 + li.addEventListener("click", () => {
  635 + window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);
  636 + });
  637 + }
  638 +}
  639 +
  640 +// Called both on initial mount (deep link / dashboard click landed here with
  641 +// an org_node already known) and from the inline org-strip's own click
  642 +// handler (already on this page -- 02-V happy path 2.2 explicitly wants an
  643 +// in-page scroll, not another full page render). Scrolls to the DAG panel
  644 +// first (renders whenever graph_manifest exists), falls back to the to-do
  645 +// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成
  646 +// Work Graph" empty states -- never throws if a target is missing.
  647 +function focusOrgNodePanel(content, task, orgNode) {
  648 + for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {
  649 + el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode);
  650 + }
  651 + const comboContent = content.querySelector("#org_combo_content");
  652 + if (comboContent) {
  653 + comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode);
  654 + wireOrgComboArtifactClicks(content);
  655 + }
  656 + const run = getLatestRunForOrgNode(task, orgNode);
  657 + let target = run ? document.getElementById("graph-panel-" + run.run_id) : null;
  658 + if (!target && run) target = document.getElementById("todo-tree-" + run.run_id);
  659 + if (!target) target = content.querySelector("#org_combo_panel");
  660 + if (target && target.scrollIntoView) {
  661 + target.scrollIntoView({ behavior: "smooth", block: "start" });
  662 + target.classList.add("org-highlight-flash");
  663 + setTimeout(() => target.classList.remove("org-highlight-flash"), 1600);
  664 + }
  665 +}
  666 +
540 667 function renderDashboard() {
541 668 if (!getCurrentProjectId()) return renderNoProjectSelected();
542 669 return `
... ... @@ -585,16 +712,13 @@ function mountDashboard() {
585 712 const quotaBanner = document.getElementById("quota_info_banner");
586 713 const wsBanner = document.getElementById("ws_disconnected_banner");
587 714 const recentEvents = [];
  715 + // Set right before every renderOrgStrip() call below -- org-strip clicks
  716 + // need to know which task_id to route to (#/tasks/:task_id/org/:org_node).
  717 + let orgStripTaskId = null;
588 718  
589 719 function renderOrgStrip(activeOrgNode, runStatusByOrgNode) {
590   - orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => {
591   - const isActive = activeOrgNode === node;
592   - const runStatus = runStatusByOrgNode[node];
593   - const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
594   - const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
595   - return '<div class="org-node' + (isActive ? " active" : "") + '"><div class="role">'
596   - + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow;
597   - }).join("");
  720 + orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode);
  721 + mountOrgStripClicks(orgStrip, orgStripTaskId);
598 722 }
599 723  
600 724 async function refreshTasksAndOrgGraph() {
... ... @@ -602,11 +726,13 @@ function mountDashboard() {
602 726 const tasks = await resp.json();
603 727 const active = tasks.find((t) => t.status !== "done") || tasks[0];
604 728 if (!active) {
  729 + orgStripTaskId = null;
605 730 orgStrip.innerHTML = '<div class="hint">暂无运行中任务</div>';
606 731 taskSummary.innerHTML = '<div class="hint">暂无运行中任务 · <a href="#/goals/new">提出第一个目标 →</a></div>';
607 732 quotaBanner.style.display = "none";
608 733 return;
609 734 }
  735 + orgStripTaskId = active.task_id;
610 736 // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set
611 737 // only makes sense on v_task_detail (they need the task's own status
612 738 // context to pick which one to show) -- the dashboard only ever needs
... ... @@ -888,7 +1014,10 @@ function renderGraphVisualizerPanel(run, options) {
888 1014 }).join("");
889 1015  
890 1016 return (
891   - '<div class="panel"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
  1017 + // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight
  1018 + // target (focusOrgNodePanel above) -- additive, doesn't change any
  1019 + // existing class/data-* this function already emitted.
  1020 + '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
892 1021 + '<div class="work-graph">' + rowsHtml + "</div>"
893 1022 + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>"
894 1023 + "</div>"
... ... @@ -1031,11 +1160,19 @@ function renderTaskDetail(taskId) {
1031 1160 `;
1032 1161 }
1033 1162  
1034   -function mountTaskDetail(taskId) {
  1163 +function mountTaskDetail(taskId, initialOrgNode) {
1035 1164 const projectId = getCurrentProjectId();
1036 1165 if (!projectId) return;
1037 1166 const content = document.getElementById("td_content");
1038 1167  
  1168 + // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's
  1169 + // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the
  1170 + // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and
  1171 + // updated in place when the inline org-strip below is clicked while
  1172 + // already on this page, so a WS-triggered refresh() re-render keeps
  1173 + // pointing at the same node instead of losing focus.
  1174 + let focusedOrgNode = initialOrgNode || null;
  1175 +
1039 1176 // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do
1040 1177 // expand -- tracked here so a re-render (WS-triggered refresh) or leaving
1041 1178 // the page closes any live c_ws_log_tail connections instead of leaking them.
... ... @@ -1069,7 +1206,9 @@ function mountTaskDetail(taskId) {
1069 1206 );
1070 1207 }).join("");
1071 1208 return (
1072   - '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
  1209 + // id="todo-tree-<run_id>": secondary scroll/highlight target when the
  1210 + // DAG panel above didn't render (graph_manifest missing).
  1211 + '<div class="panel" id="todo-tree-' + run.run_id + '"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
1073 1212 + '<ul class="todo-tree">' + items + "</ul>"
1074 1213 + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>'
1075 1214 + "</div>"
... ... @@ -1082,10 +1221,7 @@ function mountTaskDetail(taskId) {
1082 1221 for (const run of task.work_graph_runs) {
1083 1222 if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node);
1084 1223 }
1085   - const orgStripHtml = ORG_NODE_ORDER.map((node, i) => {
1086   - const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
1087   - return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow;
1088   - }).join("");
  1224 + const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);
1089 1225  
1090 1226 // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one
1091 1227 // real thing "running" against it at a time -- exactly one of these
... ... @@ -1168,6 +1304,13 @@ function mountTaskDetail(taskId) {
1168 1304 + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>"
1169 1305 + "</div>"
1170 1306 + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>"
  1307 + // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by
  1308 + // focusOrgNodePanel() below, either right after this render (deep
  1309 + // link/dashboard click already carries an org_node) or on a later
  1310 + // inline org-strip click. Left as the default hint until then.
  1311 + + '<div class="panel" id="org_combo_panel"><h2>Org 节点详情</h2><div id="org_combo_content">'
  1312 + + '<div class="hint">点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact</div>'
  1313 + + "</div></div>"
1171 1314 + todoTreesHtml
1172 1315 + graphVisualizerHtml
1173 1316 + '<div class="tooltip" id="graph-tooltip"></div>'
... ... @@ -1191,6 +1334,22 @@ function mountTaskDetail(taskId) {
1191 1334 }
1192 1335 mountGraphVisualizerTooltips(content);
1193 1336  
  1337 + // Org Graph 三节点条点击可达详情: clicking a node here (already on
  1338 + // v_task_detail) stays on this page -- update focusedOrgNode + the URL
  1339 + // (history.replaceState, no hashchange -> no full route re-render) and
  1340 + // scroll/highlight in place, per 02-V happy path 2.2.
  1341 + for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {
  1342 + el.addEventListener("click", () => {
  1343 + focusedOrgNode = el.dataset.orgNode;
  1344 + const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode;
  1345 + if (window.location.hash !== newHash) history.replaceState(null, "", newHash);
  1346 + focusOrgNodePanel(content, task, focusedOrgNode);
  1347 + });
  1348 + }
  1349 + // Deep link / dashboard click landed here with an org_node already
  1350 + // known -- populate + scroll to it once, right after this render.
  1351 + if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode);
  1352 +
1194 1353 const resumeBtn = document.getElementById("btn_manual_resume");
1195 1354 if (resumeBtn) {
1196 1355 resumeBtn.addEventListener("click", async () => {
... ...
frontend/style.css
... ... @@ -45,6 +45,18 @@ body { font-family: system-ui, sans-serif; margin: 0; }
45 45 .org-node .role { font-weight: 600; font-size: 13px; }
46 46 .org-node .hint { margin-top: 2px; }
47 47 .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; }
  48 +
  49 +/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to
  50 + `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in
  51 + place (v_task_detail's own inline org-strip). */
  52 +.org-strip .org-node[data-org-node] { cursor: pointer; }
  53 +.org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; }
  54 +.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; }
  55 +.org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; }
  56 +@keyframes org-highlight-flash-kf {
  57 + 0% { box-shadow: 0 0 0 3px #f59e0b; }
  58 + 100% { box-shadow: none; }
  59 +}
48 60 .task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
49 61 .task-summary .row:last-child { border-bottom: none; }
50 62 .progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; }
... ...
frontend/test_org_click_through_smoke.js 0 → 100644
  1 +// Org Graph 三节点条点击可达详情 (2026-08-06 SDD) smoke test. Same
  2 +// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js
  3 +// -- loads app.js in a bare vm.Context and asserts on generated HTML
  4 +// strings / regex matches / plain data for the new top-level functions:
  5 +// - renderDashboardOrgStripHtml / renderTaskDetailOrgStripHtml: both
  6 +// org-strip render locations now carry data-org-node on every node
  7 +// (root cause from the BRD: renderOrgStrip() had zero data-* attrs).
  8 +// - ORG_NODE_DETAIL_RE: the new #/tasks/:task_id/org/:org_node route --
  9 +// legal three-way org_node enum, illegal node name, missing segment.
  10 +// - getLatestRunForOrgNode: "取最新 run" filter -- must return the
  11 +// started_at-latest (array-last) entry for a given org_node, not the
  12 +// first, and must not merge two runs' nodes together.
  13 +// - renderOrgComboPanelContent: empty-state (no run yet) and populated
  14 +// (with/without artifacts) cases, always via the existing simple-list
  15 +// markup pattern -- no separate artifact-content renderer invented here.
  16 +//
  17 +// Run: node frontend/test_org_click_through_smoke.js
  18 +
  19 +"use strict";
  20 +const fs = require("fs");
  21 +const path = require("path");
  22 +const vm = require("vm");
  23 +const assert = require("assert");
  24 +
  25 +function makeFakeDiv() {
  26 + let text = "";
  27 + return {
  28 + set textContent(v) { text = v == null ? "" : String(v); },
  29 + get textContent() { return text; },
  30 + get innerHTML() {
  31 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  32 + },
  33 + };
  34 +}
  35 +
  36 +const sandbox = {
  37 + document: {
  38 + createElement: () => makeFakeDiv(),
  39 + getElementById: () => null,
  40 + querySelectorAll: () => [],
  41 + addEventListener: () => {},
  42 + },
  43 + window: {
  44 + location: { protocol: "http:", host: "localhost", hash: "" },
  45 + addEventListener: () => {},
  46 + },
  47 + localStorage: { getItem: () => null, setItem: () => {} },
  48 + console,
  49 +};
  50 +vm.createContext(sandbox);
  51 +
  52 +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
  53 +// Top-level `const` regexes stay lexically scoped to the script and never
  54 +// reach the sandbox object (same Annex B quirk test_graph_console_smoke.js
  55 +// already documents for ARTIFACT_DETAIL_RE) -- re-export what this test needs.
  56 +const augmentedSrc = src + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n";
  57 +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
  58 +
  59 +let assertionCount = 0;
  60 +function check(condition, message) {
  61 + assertionCount++;
  62 + assert.ok(condition, message);
  63 +}
  64 +
  65 +// ---- 1. data-org-node on both org-strip render locations ----
  66 +{
  67 + const dashboardHtml = sandbox.renderDashboardOrgStripHtml("coder", { planner: "done", coder: "running" });
  68 + for (const node of ["planner", "coder", "validator"]) {
  69 + check(dashboardHtml.includes('data-org-node="' + node + '"'), "dashboard org-strip must carry data-org-node for " + node);
  70 + }
  71 + check(dashboardHtml.includes('class="org-node active" data-org-node="coder"'), "active node keeps its existing .active class alongside the new attribute");
  72 +
  73 + const activeSet = new Set(["planner"]);
  74 + const taskDetailHtml = sandbox.renderTaskDetailOrgStripHtml(activeSet);
  75 + for (const node of ["planner", "coder", "validator"]) {
  76 + check(taskDetailHtml.includes('data-org-node="' + node + '"'), "v_task_detail inline org-strip must carry data-org-node for " + node);
  77 + }
  78 +}
  79 +
  80 +// ---- 2. ORG_NODE_DETAIL_RE: happy / boundary / failure ----
  81 +{
  82 + const legal = "#/tasks/abc-123/org/coder".match(sandbox.ORG_NODE_DETAIL_RE);
  83 + check(legal, "legal task_id + coder must match");
  84 + check(legal[1] === "abc-123" && legal[2] === "coder", "captured groups must be task_id and org_node");
  85 +
  86 + for (const node of ["planner", "coder", "validator"]) {
  87 + check(("#/tasks/t1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), "all three org_node enum values must match: " + node);
  88 + }
  89 +
  90 + check(!"#/tasks/t1/org/reviewer".match(sandbox.ORG_NODE_DETAIL_RE), "illegal org_node (not in the 3-way enum) must not match");
  91 + check(!"#/tasks/t1/org/".match(sandbox.ORG_NODE_DETAIL_RE), "missing org_node segment must not match");
  92 + check(!"#/tasks/t1".match(sandbox.ORG_NODE_DETAIL_RE), "plain #/tasks/:id (no /org/ suffix) must not match this regex");
  93 +}
  94 +
  95 +// ---- 3. getLatestRunForOrgNode: latest-wins, no cross-run merging ----
  96 +{
  97 + const task = {
  98 + work_graph_runs: [
  99 + { run_id: "run-old", org_node: "planner", status: "done", started_at: "2026-08-01 00:00:00" },
  100 + { run_id: "run-new", org_node: "planner", status: "done", started_at: "2026-08-02 00:00:00" },
  101 + { run_id: "run-coder", org_node: "coder", status: "running", started_at: "2026-08-02 01:00:00" },
  102 + ],
  103 + };
  104 + const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner");
  105 + check(latestPlanner.run_id === "run-new", "must return the array-last (time-latest) run for a repeated org_node, not the first");
  106 +
  107 + const latestCoder = sandbox.getLatestRunForOrgNode(task, "coder");
  108 + check(latestCoder.run_id === "run-coder", "single-run org_node resolves to that one run");
  109 +
  110 + const noValidator = sandbox.getLatestRunForOrgNode(task, "validator");
  111 + check(noValidator === null, "org_node with zero runs must resolve to null, not throw or return undefined-shaped data");
  112 +}
  113 +
  114 +// ---- 4. renderOrgComboPanelContent: empty state + artifact filtering ----
  115 +{
  116 + const emptyTask = { work_graph_runs: [], artifacts: [] };
  117 + const emptyHtml = sandbox.renderOrgComboPanelContent(emptyTask, "validator");
  118 + check(emptyHtml.includes("尚未开始"), "no-run org_node must render an explicit empty state, not blank/undefined");
  119 +
  120 + const taskWithRun = {
  121 + work_graph_runs: [
  122 + { run_id: "run-1", org_node: "coder", status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } },
  123 + ],
  124 + artifacts: [
  125 + { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" },
  126 + { artifact_id: "art-2", produced_by_run_id: "run-other", file_path: "src/y.py", artifact_type: "code_diff", validation_status: "passed" },
  127 + ],
  128 + };
  129 + const populatedHtml = sandbox.renderOrgComboPanelContent(taskWithRun, "coder");
  130 + check(populatedHtml.includes("run-1"), "combo content must name the resolved run_id");
  131 + check(populatedHtml.includes('data-artifact-id="art-1"'), "artifact produced by the resolved run must be listed");
  132 + check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out");
  133 +
  134 + const taskNoArtifacts = {
  135 + work_graph_runs: [{ run_id: "run-2", org_node: "coder", status: "failed", graph_manifest: null }],
  136 + artifacts: [],
  137 + };
  138 + const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder");
  139 + check(noArtifactsHtml.includes("暂无 Artifact"), "run with zero artifacts must show the existing empty-state text, not blank/error");
  140 +}
  141 +
  142 +console.log(
  143 + "frontend/test_org_click_through_smoke.js: all " + assertionCount + " assertions passed"
  144 +);
... ...