Commit be5c39902ae31e59fac48f5749c728f9a9cf4571

Authored by graphlemon
1 parent fb1ff76d

graphlemon: code_diff artifact for task 837fc183-f87f-41d9-8bc9-6262d7172834

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