Commit e9bb6feb1eef1790706bbb98896c68b0e1ded1fe

Authored by bboonnee
1 parent 00d0d9c6

refactor: reuse v_graph_console's Org meta-graph in v_task_detail

v_task_detail's org-strip only ever covered planner/coder/validator -- no way to
see planner_reviewer's verdict trail from the task detail page at all, only from
v_graph_console. Rather than maintain two parallel, drifting implementations of
"org node status for this task", v_task_detail now reuses the same 4-stage Org
meta-graph component (renderOrgMetaGraph/mountOrgMetaGraph) v_graph_console
already has.

mountOrgMetaGraph/renderOrgMetaStageDetailHtml gain an optional enableNodeClick
option so v_task_detail's usage gets real per-node live log-tail expansion
(wired through the existing activeLogViewers/closeAllLogViewers/mountLogViewer
machinery) while v_graph_console keeps its existing read-only snapshot
behavior unchanged. ORG_NODE_DETAIL_RE now also accepts planner_reviewer so
deep links into the 4th stage are reachable; landing on one auto-expands and
scrolls to it (focusOrgMetaStage), matching the old focusOrgNodePanel's
behavior. Dashboard's own 3-node org-strip is untouched.

Deleted the now-dead renderTaskDetailOrgStripHtml/renderOrgComboPanelContent/
wireOrgComboArtifactClicks/focusOrgNodePanel.

node frontend/test_graph_console_smoke.js passes (extended with coverage for
the regex widening and the enableNodeClick threading), independently
re-verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
frontend/app.js
@@ -97,13 +97,17 @@ const routes = { @@ -97,13 +97,17 @@ const routes = {
97 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; 97 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
98 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; 98 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
99 const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; 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 +// Org Graph 三节点条点击可达详情 (2026-08-06 SDD, extended 2026-08-13 to 4
  101 +// values): v_dashboard's 3-node org-strip and v_task_detail's Org meta-graph
  102 +// (now reused from v_graph_console, see mountTaskDetail below) both route
  103 +// here. `:org_node` is restricted to the four real values on purpose --
  104 +// anything else (typo, stray path segment) falls through to the "Not found"
  105 +// fallback in render() instead of reaching renderTaskDetail/mountTaskDetail
  106 +// with a bogus org_node that would otherwise need its own defensive checks
  107 +// further down. `planner_reviewer` was added so deep links into the 4th Org
  108 +// meta-graph stage are reachable, even though v_dashboard's own org-strip
  109 +// stays 3-node (planner/coder/validator only) and never links to it.
  110 +const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|planner_reviewer|coder|validator)$/;
107 // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) 111 // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`)
108 // previously had nowhere real to send a click -- it just called 112 // previously had nowhere real to send a click -- it just called
109 // onSwitchToTask to switch v_graph_console's own task selector. This is the 113 // onSwitchToTask to switch v_graph_console's own task selector. This is the
@@ -722,17 +726,6 @@ function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { @@ -722,17 +726,6 @@ function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
722 }).join(""); 726 }).join("");
723 } 727 }
724 728
725 -// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint  
726 -// split), `activeSet` already carries different semantics than the  
727 -// dashboard's single activeOrgNode -- kept as its own function rather than  
728 -// forcing the two call sites to share one shape.  
729 -function renderTaskDetailOrgStripHtml(activeSet) {  
730 - return ORG_NODE_ORDER.map((node, i) => {  
731 - const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";  
732 - return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '" data-org-node="' + node + '">' + node + "</div>" + arrow;  
733 - }).join("");  
734 -}  
735 -  
736 function mountOrgStripClicks(container, taskId) { 729 function mountOrgStripClicks(container, taskId) {
737 if (!taskId) return; 730 if (!taskId) return;
738 for (const el of container.querySelectorAll(".org-node[data-org-node]")) { 731 for (const el of container.querySelectorAll(".org-node[data-org-node]")) {
@@ -742,72 +735,6 @@ function mountOrgStripClicks(container, taskId) { @@ -742,72 +735,6 @@ function mountOrgStripClicks(container, taskId) {
742 } 735 }
743 } 736 }
744 737
745 -// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the  
746 -// artifact list's own markup pattern (li[data-artifact-id], same as  
747 -// renderArtifactSummaryList) filtered down to this run's own artifacts --  
748 -// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact  
749 -// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here  
750 -// -- they're already rendered once, further down this same page, by the  
751 -// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function  
752 -// only describes which run that is and lists its artifacts.  
753 -function renderOrgComboPanelContent(task, orgNode) {  
754 - const run = getLatestRunForOrgNode(task, orgNode);  
755 - if (!run) {  
756 - return '<div class="hint">' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)</div>";  
757 - }  
758 - const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0;  
759 - const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id);  
760 - const artifactsHtml = artifacts.length ? (  
761 - '<ul class="simple-list">' + artifacts.map((a) =>  
762 - '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.file_path) + "</span><span>"  
763 - + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "</span></li>"  
764 - ).join("") + "</ul>"  
765 - ) : '<div class="hint">暂无 Artifact</div>';  
766 - return (  
767 - '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")"  
768 - + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "</div>"  
769 - + '<h3 style="margin:14px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;">该 run 产出的 Artifact</h3>'  
770 - + artifactsHtml  
771 - );  
772 -}  
773 -  
774 -function wireOrgComboArtifactClicks(scopeEl) {  
775 - const combo = scopeEl.querySelector("#org_combo_content");  
776 - if (!combo) return;  
777 - for (const li of combo.querySelectorAll("li[data-artifact-id]")) {  
778 - li.addEventListener("click", () => {  
779 - window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);  
780 - });  
781 - }  
782 -}  
783 -  
784 -// Called both on initial mount (deep link / dashboard click landed here with  
785 -// an org_node already known) and from the inline org-strip's own click  
786 -// handler (already on this page -- 02-V happy path 2.2 explicitly wants an  
787 -// in-page scroll, not another full page render). Scrolls to the DAG panel  
788 -// first (renders whenever graph_manifest exists), falls back to the to-do  
789 -// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成  
790 -// Work Graph" empty states -- never throws if a target is missing.  
791 -function focusOrgNodePanel(content, task, orgNode) {  
792 - for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {  
793 - el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode);  
794 - }  
795 - const comboContent = content.querySelector("#org_combo_content");  
796 - if (comboContent) {  
797 - comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode);  
798 - wireOrgComboArtifactClicks(content);  
799 - }  
800 - const run = getLatestRunForOrgNode(task, orgNode);  
801 - let target = run ? document.getElementById("graph-panel-" + run.run_id) : null;  
802 - if (!target && run) target = document.getElementById("todo-tree-" + run.run_id);  
803 - if (!target) target = content.querySelector("#org_combo_panel");  
804 - if (target && target.scrollIntoView) {  
805 - target.scrollIntoView({ behavior: "smooth", block: "start" });  
806 - target.classList.add("org-highlight-flash");  
807 - setTimeout(() => target.classList.remove("org-highlight-flash"), 1600);  
808 - }  
809 -}  
810 -  
811 function renderDashboard() { 738 function renderDashboard() {
812 if (!getCurrentProjectId()) return renderNoProjectSelected(); 739 if (!getCurrentProjectId()) return renderNoProjectSelected();
813 return ` 740 return `
@@ -1217,9 +1144,10 @@ function renderGraphVisualizerPanel(run, options) { @@ -1217,9 +1144,10 @@ function renderGraphVisualizerPanel(run, options) {
1217 }))); 1144 })));
1218 1145
1219 return ( 1146 return (
1220 - // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight  
1221 - // target (focusOrgNodePanel above) -- additive, doesn't change any  
1222 - // existing class/data-* this function already emitted. 1147 + // id="graph-panel-<run_id>": a stable per-run anchor (e.g. v_task_board's
  1148 + // to-do tree "同一份 graph_manifest 的另一种呈现" cross-reference) --
  1149 + // additive, doesn't change any existing class/data-* this function
  1150 + // already emitted.
1223 '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + orgNodesLabel(run) + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" 1151 '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + orgNodesLabel(run) + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
1224 + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">' 1152 + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">'
1225 + '<svg class="graph-edges-svg"></svg>' 1153 + '<svg class="graph-edges-svg"></svg>'
@@ -1426,12 +1354,13 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1426,12 +1354,13 @@ function mountTaskDetail(taskId, initialOrgNode) {
1426 if (!projectId) return; 1354 if (!projectId) return;
1427 const content = document.getElementById("td_content"); 1355 const content = document.getElementById("td_content");
1428 1356
1429 - // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's  
1430 - // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the 1357 + // Org Graph 三节点条点击可达详情 (2026-08-06 SDD, reuses v_graph_console's
  1358 + // Org meta-graph as of 2026-08-13 -- see mountOrgMetaGraph's comment):
  1359 + // which stage's panel is currently focused -- seeded from the
1431 // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and 1360 // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and
1432 - // updated in place when the inline org-strip below is clicked while  
1433 - // already on this page, so a WS-triggered refresh() re-render keeps  
1434 - // pointing at the same node instead of losing focus. 1361 + // updated in place when the meta-graph below is clicked while already on
  1362 + // this page, so a WS-triggered refresh() re-render keeps pointing at the
  1363 + // same stage instead of losing focus.
1435 let focusedOrgNode = initialOrgNode || null; 1364 let focusedOrgNode = initialOrgNode || null;
1436 1365
1437 // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do 1366 // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do
@@ -1443,6 +1372,49 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1443,6 +1372,49 @@ function mountTaskDetail(taskId, initialOrgNode) {
1443 activeLogViewers.clear(); 1372 activeLogViewers.clear();
1444 } 1373 }
1445 1374
  1375 + // Wires the Org meta-graph's own work-node click-to-expand (enableNodeClick
  1376 + // opt-in below, mirrors mountGraphConsole's onWorkNodeClick) into this
  1377 + // page's own activeLogViewers/mountLogViewer machinery instead of a
  1378 + // page-local one, so a WS-triggered refresh()/route change closes these
  1379 + // sockets the same way it already does for the to-do tree's log viewers.
  1380 + //
  1381 + // ponytail: activeLogViewers is keyed by node_exec_id only, and both the
  1382 + // to-do tree (below) and the meta-graph's expanded panels can show the
  1383 + // very same node_exec_id (a Phase 2c merged run can be "the latest run"
  1384 + // for more than one org_node, and to-do-tree/meta-graph both render off
  1385 + // the same task.work_graph_runs). If a node's log is already open in one
  1386 + // location, clicking it in the other doesn't spin up a second competing
  1387 + // c_ws_log_tail connection or a duplicate `#log-viewer-<id>` DOM id (that
  1388 + // would be a real bug) -- it just scrolls to the already-open one instead
  1389 + // of also mounting a second copy there. Ceiling: the second location shows
  1390 + // an empty slot until the first is closed, rather than a live mirrored
  1391 + // view. Upgrade path if that turns out to matter in practice: a
  1392 + // location-aware key (e.g. `locationId + ":" + nodeExecId`) with each
  1393 + // entry recording which slot currently owns the DOM, so either location's
  1394 + // click can steal/re-home it instead of just deflecting.
  1395 + function onOrgMetaWorkNodeClick(nodeExecId, status, nodeEl) {
  1396 + const slot = nodeEl.querySelector(".log-viewer-slot");
  1397 + if (!slot) return;
  1398 + if (slot.querySelector(".log-viewer")) {
  1399 + // This slot owns the mounted viewer -- close it for real.
  1400 + if (activeLogViewers.has(nodeExecId)) {
  1401 + activeLogViewers.get(nodeExecId)();
  1402 + activeLogViewers.delete(nodeExecId);
  1403 + }
  1404 + slot.innerHTML = "";
  1405 + nodeEl.classList.remove("expanded");
  1406 + return;
  1407 + }
  1408 + if (activeLogViewers.has(nodeExecId)) {
  1409 + const existing = document.getElementById("log-viewer-" + nodeExecId);
  1410 + if (existing && existing.scrollIntoView) existing.scrollIntoView({ behavior: "smooth", block: "center" });
  1411 + return;
  1412 + }
  1413 + slot.innerHTML = '<div id="todo-detail-' + nodeExecId + '">' + renderLogViewerHtml(nodeExecId) + "</div>";
  1414 + activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, status));
  1415 + nodeEl.classList.add("expanded");
  1416 + }
  1417 +
1446 function renderTodoTree(run) { 1418 function renderTodoTree(run) {
1447 if (!run.graph_manifest) return ""; 1419 if (!run.graph_manifest) return "";
1448 const nodesById = {}; 1420 const nodesById = {};
@@ -1478,13 +1450,6 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1478,13 +1450,6 @@ function mountTaskDetail(taskId, initialOrgNode) {
1478 1450
1479 function renderDetail(task) { 1451 function renderDetail(task) {
1480 closeAllLogViewers(); 1452 closeAllLogViewers();
1481 - const activeSet = new Set([task.assigned_org_node]);  
1482 - for (const run of task.work_graph_runs) {  
1483 - if (run.status !== "done" && run.status !== "failed") {  
1484 - for (const orgNode of run.org_nodes_involved || []) activeSet.add(orgNode);  
1485 - }  
1486 - }  
1487 - const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);  
1488 1453
1489 // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one 1454 // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one
1490 // real thing "running" against it at a time -- exactly one of these 1455 // real thing "running" against it at a time -- exactly one of these
@@ -1566,14 +1531,13 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1566,14 +1531,13 @@ function mountTaskDetail(taskId, initialOrgNode) {
1566 + "<div><b>ASSIGNED_ORG_NODE</b>" + task.assigned_org_node + "</div>" 1531 + "<div><b>ASSIGNED_ORG_NODE</b>" + task.assigned_org_node + "</div>"
1567 + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>" 1532 + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>"
1568 + "</div>" 1533 + "</div>"
1569 - + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>"  
1570 - // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by  
1571 - // focusOrgNodePanel() below, either right after this render (deep  
1572 - // link/dashboard click already carries an org_node) or on a later  
1573 - // inline org-strip click. Left as the default hint until then.  
1574 - + '<div class="panel" id="org_combo_panel"><h2>Org 节点详情</h2><div id="org_combo_content">'  
1575 - + '<div class="hint">点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact</div>'  
1576 - + "</div></div>" 1534 + // Org Graph panel (2026-08-13): reuses v_graph_console's 4-stage Org
  1535 + // meta-graph (renderOrgMetaGraph/mountOrgMetaGraph, planner ->
  1536 + // planner_reviewer -> coder -> validator) instead of this page's old
  1537 + // 3-node org-strip + separate "Org 节点详情" combo panel -- gains
  1538 + // planner_reviewer visibility for free. Filled in and wired up right
  1539 + // after this innerHTML assignment (needs a real container element).
  1540 + + '<div class="panel"><h2>Org Graph</h2><div id="td_org_meta_graph"></div></div>'
1577 + todoTreesHtml 1541 + todoTreesHtml
1578 + graphVisualizerHtml 1542 + graphVisualizerHtml
1579 + '<div class="tooltip" id="graph-tooltip"></div>' 1543 + '<div class="tooltip" id="graph-tooltip"></div>'
@@ -1598,21 +1562,36 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1598,21 +1562,36 @@ function mountTaskDetail(taskId, initialOrgNode) {
1598 mountGraphVisualizerTooltips(content); 1562 mountGraphVisualizerTooltips(content);
1599 mountGraphVisualizerEdges(content); 1563 mountGraphVisualizerEdges(content);
1600 1564
1601 - // Org Graph 三节点条点击可达详情: clicking a node here (already on  
1602 - // v_task_detail) stays on this page -- update focusedOrgNode + the URL  
1603 - // (history.replaceState, no hashchange -> no full route re-render) and  
1604 - // scroll/highlight in place, per 02-V happy path 2.2.  
1605 - for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) {  
1606 - el.addEventListener("click", () => {  
1607 - focusedOrgNode = el.dataset.orgNode; 1565 + // Org Graph panel: render + wire the reused Org meta-graph.
  1566 + // enableNodeClick:true + onNodeClick opts into real per-node live
  1567 + // log-tail expansion via this page's own machinery above --
  1568 + // v_graph_console's own usage of mountOrgMetaGraph passes no options at
  1569 + // all, so it stays enableNodeClick:false/read-only, unaffected by this.
  1570 + // onStageExpand mirrors what the old org-strip's click handler used to
  1571 + // do: update focusedOrgNode + the URL (history.replaceState, no
  1572 + // hashchange -> no full route re-render) and the focused highlight, per
  1573 + // 02-V happy path 2.2 -- just without a scroll, since the panel now
  1574 + // expands in place right where the click happened instead of pointing
  1575 + // at a separate DAG section further down the page.
  1576 + const orgMetaGraphEl = content.querySelector("#td_org_meta_graph");
  1577 + orgMetaGraphEl.innerHTML = renderOrgMetaGraph(task);
  1578 + const orgMetaGraphOptions = {
  1579 + enableNodeClick: true,
  1580 + onNodeClick: onOrgMetaWorkNodeClick,
  1581 + onStageExpand: (stage) => {
  1582 + focusedOrgNode = stage;
1608 const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; 1583 const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode;
1609 if (window.location.hash !== newHash) history.replaceState(null, "", newHash); 1584 if (window.location.hash !== newHash) history.replaceState(null, "", newHash);
1610 - focusOrgNodePanel(content, task, focusedOrgNode);  
1611 - });  
1612 - } 1585 + for (const el of orgMetaGraphEl.querySelectorAll(".graph-topo-node[data-meta-stage]")) {
  1586 + el.classList.toggle("org-node-focused", el.dataset.metaStage === focusedOrgNode);
  1587 + }
  1588 + },
  1589 + };
  1590 + mountOrgMetaGraph(orgMetaGraphEl, task, orgMetaGraphOptions);
1613 // Deep link / dashboard click landed here with an org_node already 1591 // Deep link / dashboard click landed here with an org_node already
1614 - // known -- populate + scroll to it once, right after this render.  
1615 - if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode); 1592 + // known -- expand + scroll/highlight it once, right after this render
  1593 + // (equivalent to the old focusOrgNodePanel()).
  1594 + if (focusedOrgNode) focusOrgMetaStage(orgMetaGraphEl, task, focusedOrgNode, orgMetaGraphOptions);
1616 1595
1617 const resumeBtn = document.getElementById("btn_manual_resume"); 1596 const resumeBtn = document.getElementById("btn_manual_resume");
1618 if (resumeBtn) { 1597 if (resumeBtn) {
@@ -2012,41 +1991,90 @@ function renderPlannerReviewEventsList(events) { @@ -2012,41 +1991,90 @@ function renderPlannerReviewEventsList(events) {
2012 ).join("") + "</ul>"; 1991 ).join("") + "</ul>";
2013 } 1992 }
2014 1993
2015 -// planner/coder/validator branch mirrors renderOrgComboPanelContent's own  
2016 -// empty-run/empty-DAG fallback text -- same two states, this is just the  
2017 -// meta-graph's own expand target rather than v_task_detail's combo panel.  
2018 -function renderOrgMetaStageDetailHtml(task, stage) { 1994 +// planner/coder/validator branch renders the same empty-run/empty-DAG
  1995 +// fallback text v_task_detail's old combo panel used to. `enableNodeClick`
  1996 +// (2026-08-13, Org Graph unification/console reuse into v_task_detail) is
  1997 +// threaded straight into renderGraphVisualizerPanel's own existing options
  1998 +// param -- v_graph_console's call sites never pass it (stays `undefined` ->
  1999 +// falsy -> `{ enableNodeClick: false }`, byte-for-byte what this already
  2000 +// rendered), while v_task_detail's usage below opts in for real per-node
  2001 +// live log-tail expansion instead of the read-only DAG snapshot.
  2002 +function renderOrgMetaStageDetailHtml(task, stage, enableNodeClick) {
2019 if (stage === "planner_reviewer") { 2003 if (stage === "planner_reviewer") {
2020 return '<div class="panel"><h2>Planner 产出复核记录</h2>' + renderPlannerReviewEventsList(task.planner_review_events) + "</div>"; 2004 return '<div class="panel"><h2>Planner 产出复核记录</h2>' + renderPlannerReviewEventsList(task.planner_review_events) + "</div>";
2021 } 2005 }
2022 const run = getLatestRunForOrgNode(task, stage); 2006 const run = getLatestRunForOrgNode(task, stage);
2023 if (!run) return '<div class="hint">' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)</div>"; 2007 if (!run) return '<div class="hint">' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)</div>";
2024 - const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: false }); 2008 + const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: !!enableNodeClick });
2025 return panelHtml 2009 return panelHtml
2026 ? panelHtml + renderLoopBackTimelineHtml(run) 2010 ? panelHtml + renderLoopBackTimelineHtml(run)
2027 : '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph</div>"; 2011 : '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph</div>";
2028 } 2012 }
2029 2013
2030 -function mountOrgMetaGraph(container, task) { 2014 +// `options` (2026-08-13, v_task_detail reuse): `{ enableNodeClick, onNodeClick,
  2015 +// onStageExpand }`, all optional -- v_graph_console's existing call
  2016 +// (`mountOrgMetaGraph(orgMetaGraphEl, task)`, no third arg) leaves every one
  2017 +// of them `undefined`, so `enableNodeClick` stays falsy (read-only DAG
  2018 +// snapshot, unchanged) and `mountWorkNodeClickExpand`/`onStageExpand` are
  2019 +// never invoked -- v_graph_console's behavior is untouched by this refactor.
  2020 +// v_task_detail passes `enableNodeClick: true` + a real `onNodeClick` wired
  2021 +// to its own `activeLogViewers` map (mountTaskDetail below) for real
  2022 +// per-node live log-tail expansion, and `onStageExpand` to keep the deep-link
  2023 +// URL/focus state in sync when a stage is opened by a direct click (not just
  2024 +// the initial deep-link landing, which calls expandOrgMetaStage directly).
  2025 +function expandOrgMetaStage(container, task, stage, options) {
  2026 + const detail = container.querySelector("#org-meta-detail-" + stage);
  2027 + if (!detail) return;
  2028 + const enableNodeClick = options && options.enableNodeClick;
  2029 + detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage, enableNodeClick);
  2030 + detail.style.display = "block";
  2031 + mountGraphVisualizerTooltips(detail);
  2032 + mountGraphVisualizerEdges(detail);
  2033 + mountLoopBackTimeline(detail);
  2034 + if (enableNodeClick) mountWorkNodeClickExpand(detail, { onNodeClick: options.onNodeClick });
  2035 +}
  2036 +
  2037 +function collapseOrgMetaStage(container, stage) {
  2038 + const detail = container.querySelector("#org-meta-detail-" + stage);
  2039 + if (!detail) return;
  2040 + detail.style.display = "none";
  2041 + detail.innerHTML = "";
  2042 +}
  2043 +
  2044 +function mountOrgMetaGraph(container, task, options) {
2031 for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) { 2045 for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) {
2032 el.addEventListener("click", () => { 2046 el.addEventListener("click", () => {
2033 const stage = el.dataset.metaStage; 2047 const stage = el.dataset.metaStage;
2034 const detail = container.querySelector("#org-meta-detail-" + stage); 2048 const detail = container.querySelector("#org-meta-detail-" + stage);
2035 if (!detail) return; 2049 if (!detail) return;
2036 if (detail.style.display !== "none") { 2050 if (detail.style.display !== "none") {
2037 - detail.style.display = "none";  
2038 - detail.innerHTML = ""; 2051 + collapseOrgMetaStage(container, stage);
2039 return; 2052 return;
2040 } 2053 }
2041 - detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage);  
2042 - detail.style.display = "block";  
2043 - mountGraphVisualizerTooltips(detail);  
2044 - mountGraphVisualizerEdges(detail);  
2045 - mountLoopBackTimeline(detail); 2054 + expandOrgMetaStage(container, task, stage, options);
  2055 + if (options && options.onStageExpand) options.onStageExpand(stage);
2046 }); 2056 });
2047 } 2057 }
2048 } 2058 }
2049 2059
  2060 +// Deep-link / focus entry point (2026-08-13, replaces the old
  2061 +// focusOrgNodePanel): called on initial mount when the route already carries
  2062 +// an org_node (#/tasks/:task_id/org/:org_node) -- expands that stage's panel
  2063 +// and scrolls/highlights it into view, equivalent to what focusOrgNodePanel
  2064 +// used to do for the old inline org-strip + combo panel.
  2065 +function focusOrgMetaStage(container, task, stage, options) {
  2066 + for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) {
  2067 + el.classList.toggle("org-node-focused", el.dataset.metaStage === stage);
  2068 + }
  2069 + expandOrgMetaStage(container, task, stage, options);
  2070 + const target = container.querySelector("#org-meta-detail-" + stage);
  2071 + if (target && target.scrollIntoView) {
  2072 + target.scrollIntoView({ behavior: "smooth", block: "start" });
  2073 + target.classList.add("org-highlight-flash");
  2074 + setTimeout(() => target.classList.remove("org-highlight-flash"), 1600);
  2075 + }
  2076 +}
  2077 +
2050 // ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3, 2078 // ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3,
2051 // 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes 2079 // 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes
2052 // run.loop_back_events (source_node_id/target_node_id/attempt_number/ 2080 // run.loop_back_events (source_node_id/target_node_id/attempt_number/
frontend/style.css
@@ -59,12 +59,14 @@ body { font-family: system-ui, sans-serif; margin: 0; } @@ -59,12 +59,14 @@ body { font-family: system-ui, sans-serif; margin: 0; }
59 .org-node .hint { margin-top: 2px; } 59 .org-node .hint { margin-top: 2px; }
60 .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; } 60 .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; }
61 61
62 -/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to  
63 - `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in  
64 - place (v_task_detail's own inline org-strip). */ 62 +/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): v_dashboard's org-strip
  63 + nodes route to `#/tasks/:task_id/org/:org_node`. v_task_detail itself
  64 + switched to the (4-stage) Org meta-graph below on 2026-08-13 -- its nodes
  65 + are `.graph-topo-node`, not `.org-node`, so `.org-node-focused` is kept
  66 + unscoped rather than duplicated per-component. */
65 .org-strip .org-node[data-org-node] { cursor: pointer; } 67 .org-strip .org-node[data-org-node] { cursor: pointer; }
66 .org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; } 68 .org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; }
67 -.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; } 69 +.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; }
68 .org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; } 70 .org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; }
69 @keyframes org-highlight-flash-kf { 71 @keyframes org-highlight-flash-kf {
70 0% { box-shadow: 0 0 0 3px #f59e0b; } 72 0% { box-shadow: 0 0 0 3px #f59e0b; }
frontend/test_graph_console_smoke.js
@@ -58,10 +58,12 @@ const src = fs.readFileSync(path.join(__dirname, &quot;app.js&quot;), &quot;utf8&quot;); @@ -58,10 +58,12 @@ const src = fs.readFileSync(path.join(__dirname, &quot;app.js&quot;), &quot;utf8&quot;);
58 // `function ...` declarations at top level become sandbox globals 58 // `function ...` declarations at top level become sandbox globals
59 // automatically (Annex B global-object semantics), but top-level `const` 59 // automatically (Annex B global-object semantics), but top-level `const`
60 // (e.g. ARTIFACT_DETAIL_RE) stays lexically scoped to the script and never 60 // (e.g. ARTIFACT_DETAIL_RE) stays lexically scoped to the script and never
61 -// reaches the sandbox object -- explicitly re-export the one const this  
62 -// test needs, in the same script/lexical scope, rather than changing app.js's 61 +// reaches the sandbox object -- explicitly re-export the consts this test
  62 +// needs, in the same script/lexical scope, rather than changing app.js's
63 // declaration style just for testability. 63 // declaration style just for testability.
64 -const augmentedSrc = src + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;\n"; 64 +const augmentedSrc = src
  65 + + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;"
  66 + + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n";
65 new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); 67 new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
66 68
67 // ---- computeGraphLayers: still the shared layering logic v_graph_console 69 // ---- computeGraphLayers: still the shared layering logic v_graph_console
@@ -228,4 +230,58 @@ new vm.Script(augmentedSrc, { filename: &quot;app.js&quot; }).runInContext(sandbox); @@ -228,4 +230,58 @@ new vm.Script(augmentedSrc, { filename: &quot;app.js&quot; }).runInContext(sandbox);
228 assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match"); 230 assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match");
229 } 231 }
230 232
  233 +// ---- ORG_NODE_DETAIL_RE (2026-08-13: v_task_detail reuses the Org
  234 +// meta-graph, extending the route's `:org_node` capture group from 3 values
  235 +// to 4 so `#/tasks/:id/org/planner_reviewer` deep links are reachable) ----
  236 +{
  237 + const match = "#/tasks/task-1/org/planner_reviewer".match(sandbox.ORG_NODE_DETAIL_RE);
  238 + assert.ok(match, "planner_reviewer must now match ORG_NODE_DETAIL_RE");
  239 + assert.strictEqual(match[1], "task-1", "captured task_id must be correct");
  240 + assert.strictEqual(match[2], "planner_reviewer", "captured org_node must be planner_reviewer");
  241 +
  242 + for (const node of ["planner", "coder", "validator"]) {
  243 + assert.ok(("#/tasks/task-1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), node + " must still match (pre-existing 3 values)");
  244 + }
  245 + assert.ok(!"#/tasks/task-1/org/bogus".match(sandbox.ORG_NODE_DETAIL_RE), "an unrecognized org_node must still fall through to Not-found, not match");
  246 +}
  247 +
  248 +// ---- renderOrgMetaStageDetailHtml (2026-08-13: v_task_detail reuse of the
  249 +// Org meta-graph): `enableNodeClick` is a new 3rd param threaded straight
  250 +// into renderGraphVisualizerPanel's own options -- the actual regression
  251 +// risk flagged for this change is v_graph_console's existing call sites
  252 +// (which never pass a 3rd arg) silently starting to render clickable nodes
  253 +// too. Pin both sides: omitted/false stays exactly what v_graph_console
  254 +// already rendered; `true` (v_task_detail's new opt-in) really does add the
  255 +// click-to-expand markup. ----
  256 +{
  257 + const task = {
  258 + planner_review_events: [],
  259 + work_graph_runs: [{
  260 + run_id: "run-meta-1", status: "running", org_nodes_involved: ["coder"],
  261 + graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] },
  262 + nodes: [{ node_id: "n1", node_exec_id: "exec-meta-1", subagent_type: "coder", status: "running" }],
  263 + }],
  264 + };
  265 +
  266 + const readOnlyHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder");
  267 + assert.ok(!readOnlyHtml.includes("clickable"), "v_graph_console's call (no 3rd arg) must stay read-only, no clickable class");
  268 + assert.ok(!readOnlyHtml.includes("data-clickable-node-exec-id"), "v_graph_console's call must not add the click-target id");
  269 + assert.ok(!readOnlyHtml.includes("log-viewer-slot"), "v_graph_console's call must not reserve a log-viewer-slot");
  270 +
  271 + const explicitFalseHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder", false);
  272 + assert.ok(!explicitFalseHtml.includes("clickable"), "an explicit false must also stay read-only");
  273 +
  274 + const clickableHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder", true);
  275 + assert.ok(clickableHtml.includes("work-node status-running clickable"), "v_task_detail's opt-in (3rd arg true) must add the clickable class");
  276 + assert.ok(clickableHtml.includes('data-clickable-node-exec-id="exec-meta-1"'), "v_task_detail's opt-in must add the real click-target id");
  277 + assert.ok(clickableHtml.includes('<div class="log-viewer-slot">'), "v_task_detail's opt-in must reserve a log-viewer-slot for mountWorkNodeClickExpand");
  278 +
  279 + // planner_reviewer never has a Work Graph run (it's a flat verdict trail,
  280 + // not a self-planned graph) -- enableNodeClick is meaningless there and
  281 + // must not throw or leak into the output.
  282 + const reviewerHtml = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer", true);
  283 + assert.ok(reviewerHtml.includes("Planner 产出复核记录"), "planner_reviewer branch renders its own verdict-trail panel regardless of enableNodeClick");
  284 + assert.ok(!reviewerHtml.includes("clickable"), "planner_reviewer branch never renders DAG markup, so no clickable class either");
  285 +}
  286 +
231 console.log("frontend/test_graph_console_smoke.js: all assertions passed"); 287 console.log("frontend/test_graph_console_smoke.js: all assertions passed");