code_diff.patch 23.6 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
diff --git a/frontend/app.js b/frontend/app.js
index da24c1e..2f17236 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -97,6 +97,13 @@ const routes = {
 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
 const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
+// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and
+// v_task_detail's org-strip nodes route here. `:org_node` is restricted to
+// the three real values on purpose -- anything else (typo, stray path
+// segment) falls through to the "Not found" fallback in render() instead of
+// reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would
+// otherwise need its own defensive checks further down.
+const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/;
 
 let currentRouteCleanup = null;
 function registerRouteCleanup(fn) {
@@ -114,9 +121,15 @@ function render() {
   let routeArgs = [];
   if (!route) {
     const taskMatch = hash.match(TASK_DETAIL_RE);
+    const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE);
     const skillMatch = hash.match(SKILL_AUDIT_RE);
     const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
-    if (taskMatch) {
+    if (orgNodeMatch) {
+      // Reuses v_task_detail itself (same render/mount) with the org_node
+      // carried through as a second arg -- no separate view/route handler.
+      route = { render: renderTaskDetail, mount: mountTaskDetail };
+      routeArgs = [orgNodeMatch[1], orgNodeMatch[2]];
+    } else if (taskMatch) {
       route = { render: renderTaskDetail, mount: mountTaskDetail };
       routeArgs = [taskMatch[1]];
     } else if (skillMatch) {
@@ -537,6 +550,120 @@ function mountBugFeedback() {
 
 const ORG_NODE_ORDER = ["planner", "coder", "validator"];
 
+// ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by
+// v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below
+// -- both mark each `.org-node` with `data-org-node`, both route through
+// `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for
+// that org_node the same way (C6: plain array filtering, no new backend
+// query params). DAG/log/artifact display itself stays entirely inside
+// renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser
+// (C3-C5) -- this block only does the "接线": which run is "latest", and
+// where on the already-rendered page to scroll/highlight.
+
+function getLatestRunForOrgNode(task, orgNode) {
+  const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode);
+  return runs.length ? runs[runs.length - 1] : null;
+}
+
+// v_dashboard's org-strip: role + hint text, only ever links out to
+// v_task_detail (a genuine page change, so plain hash assignment is fine).
+function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
+  return ORG_NODE_ORDER.map((node, i) => {
+    const isActive = activeOrgNode === node;
+    const runStatus = runStatusByOrgNode[node];
+    const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
+    const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
+    return '<div class="org-node' + (isActive ? " active" : "") + '" data-org-node="' + node + '"><div class="role">'
+      + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow;
+  }).join("");
+}
+
+// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint
+// split), `activeSet` already carries different semantics than the
+// dashboard's single activeOrgNode -- kept as its own function rather than
+// forcing the two call sites to share one shape.
+function renderTaskDetailOrgStripHtml(activeSet) {
+  return ORG_NODE_ORDER.map((node, i) => {
+    const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
+    return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '" data-org-node="' + node + '">' + node + "</div>" + arrow;
+  }).join("");
+}
+
+function mountOrgStripClicks(container, taskId) {
+  if (!taskId) return;
+  for (const el of container.querySelectorAll(".org-node[data-org-node]")) {
+    el.addEventListener("click", () => {
+      window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode;
+    });
+  }
+}
+
+// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the
+// artifact list's own markup pattern (li[data-artifact-id], same as
+// renderArtifactSummaryList) filtered down to this run's own artifacts --
+// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact
+// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here
+// -- they're already rendered once, further down this same page, by the
+// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function
+// only describes which run that is and lists its artifacts.
+function renderOrgComboPanelContent(task, orgNode) {
+  const run = getLatestRunForOrgNode(task, orgNode);
+  if (!run) {
+    return '<div class="hint">' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)</div>";
+  }
+  const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0;
+  const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id);
+  const artifactsHtml = artifacts.length ? (
+    '<ul class="simple-list">' + artifacts.map((a) =>
+      '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.file_path) + "</span><span>"
+      + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "</span></li>"
+    ).join("") + "</ul>"
+  ) : '<div class="hint">暂无 Artifact</div>';
+  return (
+    '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")"
+    + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "</div>"
+    + '<h3 style="margin:14px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;">该 run 产出的 Artifact</h3>'
+    + artifactsHtml
+  );
+}
+
+function wireOrgComboArtifactClicks(scopeEl) {
+  const combo = scopeEl.querySelector("#org_combo_content");
+  if (!combo) return;
+  for (const li of combo.querySelectorAll("li[data-artifact-id]")) {
+    li.addEventListener("click", () => {
+      window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);
+    });
+  }
+}
+
+// Called both on initial mount (deep link / dashboard click landed here with
+// an org_node already known) and from the inline org-strip's own click
+// handler (already on this page -- 02-V happy path 2.2 explicitly wants an
+// in-page scroll, not another full page render). Scrolls to the DAG panel
+// first (renders whenever graph_manifest exists), falls back to the to-do
+// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成
+// Work Graph" empty states -- never throws if a target is missing.
+function focusOrgNodePanel(content, task, orgNode) {
+  for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {
+    el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode);
+  }
+  const comboContent = content.querySelector("#org_combo_content");
+  if (comboContent) {
+    comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode);
+    wireOrgComboArtifactClicks(content);
+  }
+  const run = getLatestRunForOrgNode(task, orgNode);
+  let target = run ? document.getElementById("graph-panel-" + run.run_id) : null;
+  if (!target && run) target = document.getElementById("todo-tree-" + run.run_id);
+  if (!target) target = content.querySelector("#org_combo_panel");
+  if (target && target.scrollIntoView) {
+    target.scrollIntoView({ behavior: "smooth", block: "start" });
+    target.classList.add("org-highlight-flash");
+    setTimeout(() => target.classList.remove("org-highlight-flash"), 1600);
+  }
+}
+
 function renderDashboard() {
   if (!getCurrentProjectId()) return renderNoProjectSelected();
   return `
@@ -585,16 +712,13 @@ function mountDashboard() {
   const quotaBanner = document.getElementById("quota_info_banner");
   const wsBanner = document.getElementById("ws_disconnected_banner");
   const recentEvents = [];
+  // Set right before every renderOrgStrip() call below -- org-strip clicks
+  // need to know which task_id to route to (#/tasks/:task_id/org/:org_node).
+  let orgStripTaskId = null;
 
   function renderOrgStrip(activeOrgNode, runStatusByOrgNode) {
-    orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => {
-      const isActive = activeOrgNode === node;
-      const runStatus = runStatusByOrgNode[node];
-      const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
-      const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
-      return '<div class="org-node' + (isActive ? " active" : "") + '"><div class="role">'
-        + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow;
-    }).join("");
+    orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode);
+    mountOrgStripClicks(orgStrip, orgStripTaskId);
   }
 
   async function refreshTasksAndOrgGraph() {
@@ -602,11 +726,13 @@ function mountDashboard() {
     const tasks = await resp.json();
     const active = tasks.find((t) => t.status !== "done") || tasks[0];
     if (!active) {
+      orgStripTaskId = null;
       orgStrip.innerHTML = '<div class="hint">暂无运行中任务</div>';
       taskSummary.innerHTML = '<div class="hint">暂无运行中任务 · <a href="#/goals/new">提出第一个目标 →</a></div>';
       quotaBanner.style.display = "none";
       return;
     }
+    orgStripTaskId = active.task_id;
     // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set
     // only makes sense on v_task_detail (they need the task's own status
     // context to pick which one to show) -- the dashboard only ever needs
@@ -888,7 +1014,10 @@ function renderGraphVisualizerPanel(run, options) {
   }).join("");
 
   return (
-    '<div class="panel"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
+    // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight
+    // target (focusOrgNodePanel above) -- additive, doesn't change any
+    // existing class/data-* this function already emitted.
+    '<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>"
     + '<div class="work-graph">' + rowsHtml + "</div>"
     + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>"
     + "</div>"
@@ -1031,11 +1160,19 @@ function renderTaskDetail(taskId) {
   `;
 }
 
-function mountTaskDetail(taskId) {
+function mountTaskDetail(taskId, initialOrgNode) {
   const projectId = getCurrentProjectId();
   if (!projectId) return;
   const content = document.getElementById("td_content");
 
+  // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's
+  // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the
+  // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and
+  // updated in place when the inline org-strip below is clicked while
+  // already on this page, so a WS-triggered refresh() re-render keeps
+  // pointing at the same node instead of losing focus.
+  let focusedOrgNode = initialOrgNode || null;
+
   // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do
   // expand -- tracked here so a re-render (WS-triggered refresh) or leaving
   // the page closes any live c_ws_log_tail connections instead of leaking them.
@@ -1069,7 +1206,9 @@ function mountTaskDetail(taskId) {
       );
     }).join("");
     return (
-      '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
+      // id="todo-tree-<run_id>": secondary scroll/highlight target when the
+      // DAG panel above didn't render (graph_manifest missing).
+      '<div class="panel" id="todo-tree-' + run.run_id + '"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
       + '<ul class="todo-tree">' + items + "</ul>"
       + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>'
       + "</div>"
@@ -1082,10 +1221,7 @@ function mountTaskDetail(taskId) {
     for (const run of task.work_graph_runs) {
       if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node);
     }
-    const orgStripHtml = ORG_NODE_ORDER.map((node, i) => {
-      const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
-      return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow;
-    }).join("");
+    const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);
 
     // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one
     // real thing "running" against it at a time -- exactly one of these
@@ -1168,6 +1304,13 @@ function mountTaskDetail(taskId) {
       + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>"
       + "</div>"
       + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>"
+      // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by
+      // focusOrgNodePanel() below, either right after this render (deep
+      // link/dashboard click already carries an org_node) or on a later
+      // inline org-strip click. Left as the default hint until then.
+      + '<div class="panel" id="org_combo_panel"><h2>Org 节点详情</h2><div id="org_combo_content">'
+      + '<div class="hint">点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact</div>'
+      + "</div></div>"
       + todoTreesHtml
       + graphVisualizerHtml
       + '<div class="tooltip" id="graph-tooltip"></div>'
@@ -1191,6 +1334,22 @@ function mountTaskDetail(taskId) {
     }
     mountGraphVisualizerTooltips(content);
 
+    // Org Graph 三节点条点击可达详情: clicking a node here (already on
+    // v_task_detail) stays on this page -- update focusedOrgNode + the URL
+    // (history.replaceState, no hashchange -> no full route re-render) and
+    // scroll/highlight in place, per 02-V happy path 2.2.
+    for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {
+      el.addEventListener("click", () => {
+        focusedOrgNode = el.dataset.orgNode;
+        const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode;
+        if (window.location.hash !== newHash) history.replaceState(null, "", newHash);
+        focusOrgNodePanel(content, task, focusedOrgNode);
+      });
+    }
+    // Deep link / dashboard click landed here with an org_node already
+    // known -- populate + scroll to it once, right after this render.
+    if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode);
+
     const resumeBtn = document.getElementById("btn_manual_resume");
     if (resumeBtn) {
       resumeBtn.addEventListener("click", async () => {
diff --git a/frontend/style.css b/frontend/style.css
index 1ec077d..c3b4179 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -45,6 +45,18 @@ body { font-family: system-ui, sans-serif; margin: 0; }
 .org-node .role { font-weight: 600; font-size: 13px; }
 .org-node .hint { margin-top: 2px; }
 .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; }
+
+/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to
+   `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in
+   place (v_task_detail's own inline org-strip). */
+.org-strip .org-node[data-org-node] { cursor: pointer; }
+.org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; }
+.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; }
+.org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; }
+@keyframes org-highlight-flash-kf {
+  0% { box-shadow: 0 0 0 3px #f59e0b; }
+  100% { box-shadow: none; }
+}
 .task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
 .task-summary .row:last-child { border-bottom: none; }
 .progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; }
diff --git a/frontend/test_org_click_through_smoke.js b/frontend/test_org_click_through_smoke.js
new file mode 100644
index 0000000..043319b
--- /dev/null
+++ b/frontend/test_org_click_through_smoke.js
@@ -0,0 +1,144 @@
+// Org Graph 三节点条点击可达详情 (2026-08-06 SDD) smoke test. Same
+// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js
+// -- loads app.js in a bare vm.Context and asserts on generated HTML
+// strings / regex matches / plain data for the new top-level functions:
+//   - renderDashboardOrgStripHtml / renderTaskDetailOrgStripHtml: both
+//     org-strip render locations now carry data-org-node on every node
+//     (root cause from the BRD: renderOrgStrip() had zero data-* attrs).
+//   - ORG_NODE_DETAIL_RE: the new #/tasks/:task_id/org/:org_node route --
+//     legal three-way org_node enum, illegal node name, missing segment.
+//   - getLatestRunForOrgNode: "取最新 run" filter -- must return the
+//     started_at-latest (array-last) entry for a given org_node, not the
+//     first, and must not merge two runs' nodes together.
+//   - renderOrgComboPanelContent: empty-state (no run yet) and populated
+//     (with/without artifacts) cases, always via the existing simple-list
+//     markup pattern -- no separate artifact-content renderer invented here.
+//
+// Run: node frontend/test_org_click_through_smoke.js
+
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+const assert = require("assert");
+
+function makeFakeDiv() {
+  let text = "";
+  return {
+    set textContent(v) { text = v == null ? "" : String(v); },
+    get textContent() { return text; },
+    get innerHTML() {
+      return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+    },
+  };
+}
+
+const sandbox = {
+  document: {
+    createElement: () => makeFakeDiv(),
+    getElementById: () => null,
+    querySelectorAll: () => [],
+    addEventListener: () => {},
+  },
+  window: {
+    location: { protocol: "http:", host: "localhost", hash: "" },
+    addEventListener: () => {},
+  },
+  localStorage: { getItem: () => null, setItem: () => {} },
+  console,
+};
+vm.createContext(sandbox);
+
+const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
+// Top-level `const` regexes stay lexically scoped to the script and never
+// reach the sandbox object (same Annex B quirk test_graph_console_smoke.js
+// already documents for ARTIFACT_DETAIL_RE) -- re-export what this test needs.
+const augmentedSrc = src + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n";
+new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
+
+let assertionCount = 0;
+function check(condition, message) {
+  assertionCount++;
+  assert.ok(condition, message);
+}
+
+// ---- 1. data-org-node on both org-strip render locations ----
+{
+  const dashboardHtml = sandbox.renderDashboardOrgStripHtml("coder", { planner: "done", coder: "running" });
+  for (const node of ["planner", "coder", "validator"]) {
+    check(dashboardHtml.includes('data-org-node="' + node + '"'), "dashboard org-strip must carry data-org-node for " + node);
+  }
+  check(dashboardHtml.includes('class="org-node active" data-org-node="coder"'), "active node keeps its existing .active class alongside the new attribute");
+
+  const activeSet = new Set(["planner"]);
+  const taskDetailHtml = sandbox.renderTaskDetailOrgStripHtml(activeSet);
+  for (const node of ["planner", "coder", "validator"]) {
+    check(taskDetailHtml.includes('data-org-node="' + node + '"'), "v_task_detail inline org-strip must carry data-org-node for " + node);
+  }
+}
+
+// ---- 2. ORG_NODE_DETAIL_RE: happy / boundary / failure ----
+{
+  const legal = "#/tasks/abc-123/org/coder".match(sandbox.ORG_NODE_DETAIL_RE);
+  check(legal, "legal task_id + coder must match");
+  check(legal[1] === "abc-123" && legal[2] === "coder", "captured groups must be task_id and org_node");
+
+  for (const node of ["planner", "coder", "validator"]) {
+    check(("#/tasks/t1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), "all three org_node enum values must match: " + node);
+  }
+
+  check(!"#/tasks/t1/org/reviewer".match(sandbox.ORG_NODE_DETAIL_RE), "illegal org_node (not in the 3-way enum) must not match");
+  check(!"#/tasks/t1/org/".match(sandbox.ORG_NODE_DETAIL_RE), "missing org_node segment must not match");
+  check(!"#/tasks/t1".match(sandbox.ORG_NODE_DETAIL_RE), "plain #/tasks/:id (no /org/ suffix) must not match this regex");
+}
+
+// ---- 3. getLatestRunForOrgNode: latest-wins, no cross-run merging ----
+{
+  const task = {
+    work_graph_runs: [
+      { run_id: "run-old", org_node: "planner", status: "done", started_at: "2026-08-01 00:00:00" },
+      { run_id: "run-new", org_node: "planner", status: "done", started_at: "2026-08-02 00:00:00" },
+      { run_id: "run-coder", org_node: "coder", status: "running", started_at: "2026-08-02 01:00:00" },
+    ],
+  };
+  const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner");
+  check(latestPlanner.run_id === "run-new", "must return the array-last (time-latest) run for a repeated org_node, not the first");
+
+  const latestCoder = sandbox.getLatestRunForOrgNode(task, "coder");
+  check(latestCoder.run_id === "run-coder", "single-run org_node resolves to that one run");
+
+  const noValidator = sandbox.getLatestRunForOrgNode(task, "validator");
+  check(noValidator === null, "org_node with zero runs must resolve to null, not throw or return undefined-shaped data");
+}
+
+// ---- 4. renderOrgComboPanelContent: empty state + artifact filtering ----
+{
+  const emptyTask = { work_graph_runs: [], artifacts: [] };
+  const emptyHtml = sandbox.renderOrgComboPanelContent(emptyTask, "validator");
+  check(emptyHtml.includes("尚未开始"), "no-run org_node must render an explicit empty state, not blank/undefined");
+
+  const taskWithRun = {
+    work_graph_runs: [
+      { run_id: "run-1", org_node: "coder", status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } },
+    ],
+    artifacts: [
+      { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" },
+      { artifact_id: "art-2", produced_by_run_id: "run-other", file_path: "src/y.py", artifact_type: "code_diff", validation_status: "passed" },
+    ],
+  };
+  const populatedHtml = sandbox.renderOrgComboPanelContent(taskWithRun, "coder");
+  check(populatedHtml.includes("run-1"), "combo content must name the resolved run_id");
+  check(populatedHtml.includes('data-artifact-id="art-1"'), "artifact produced by the resolved run must be listed");
+  check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out");
+
+  const taskNoArtifacts = {
+    work_graph_runs: [{ run_id: "run-2", org_node: "coder", status: "failed", graph_manifest: null }],
+    artifacts: [],
+  };
+  const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder");
+  check(noArtifactsHtml.includes("暂无 Artifact"), "run with zero artifacts must show the existing empty-state text, not blank/error");
+}
+
+console.log(
+  "frontend/test_org_click_through_smoke.js: all " + assertionCount + " assertions passed"
+);