Commit e9bb6feb1eef1790706bbb98896c68b0e1ded1fe
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>
Showing
3 changed files
with
225 additions
and
139 deletions
frontend/app.js
| ... | ... | @@ -97,13 +97,17 @@ 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 | +// 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 | 111 | // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) |
| 108 | 112 | // previously had nowhere real to send a click -- it just called |
| 109 | 113 | // onSwitchToTask to switch v_graph_console's own task selector. This is the |
| ... | ... | @@ -722,17 +726,6 @@ function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { |
| 722 | 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 | 729 | function mountOrgStripClicks(container, taskId) { |
| 737 | 730 | if (!taskId) return; |
| 738 | 731 | for (const el of container.querySelectorAll(".org-node[data-org-node]")) { |
| ... | ... | @@ -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 | 738 | function renderDashboard() { |
| 812 | 739 | if (!getCurrentProjectId()) return renderNoProjectSelected(); |
| 813 | 740 | return ` |
| ... | ... | @@ -1217,9 +1144,10 @@ function renderGraphVisualizerPanel(run, options) { |
| 1217 | 1144 | }))); |
| 1218 | 1145 | |
| 1219 | 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 | 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 | 1152 | + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">' |
| 1225 | 1153 | + '<svg class="graph-edges-svg"></svg>' |
| ... | ... | @@ -1426,12 +1354,13 @@ function mountTaskDetail(taskId, initialOrgNode) { |
| 1426 | 1354 | if (!projectId) return; |
| 1427 | 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 | 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 | 1364 | let focusedOrgNode = initialOrgNode || null; |
| 1436 | 1365 | |
| 1437 | 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 | 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 | 1418 | function renderTodoTree(run) { |
| 1447 | 1419 | if (!run.graph_manifest) return ""; |
| 1448 | 1420 | const nodesById = {}; |
| ... | ... | @@ -1478,13 +1450,6 @@ function mountTaskDetail(taskId, initialOrgNode) { |
| 1478 | 1450 | |
| 1479 | 1451 | function renderDetail(task) { |
| 1480 | 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 | 1454 | // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one |
| 1490 | 1455 | // real thing "running" against it at a time -- exactly one of these |
| ... | ... | @@ -1566,14 +1531,13 @@ function mountTaskDetail(taskId, initialOrgNode) { |
| 1566 | 1531 | + "<div><b>ASSIGNED_ORG_NODE</b>" + task.assigned_org_node + "</div>" |
| 1567 | 1532 | + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>" |
| 1568 | 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 | 1541 | + todoTreesHtml |
| 1578 | 1542 | + graphVisualizerHtml |
| 1579 | 1543 | + '<div class="tooltip" id="graph-tooltip"></div>' |
| ... | ... | @@ -1598,21 +1562,36 @@ function mountTaskDetail(taskId, initialOrgNode) { |
| 1598 | 1562 | mountGraphVisualizerTooltips(content); |
| 1599 | 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 | 1583 | const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; |
| 1609 | 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 | 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 | 1596 | const resumeBtn = document.getElementById("btn_manual_resume"); |
| 1618 | 1597 | if (resumeBtn) { |
| ... | ... | @@ -2012,41 +1991,90 @@ function renderPlannerReviewEventsList(events) { |
| 2012 | 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 | 2003 | if (stage === "planner_reviewer") { |
| 2020 | 2004 | return '<div class="panel"><h2>Planner 产出复核记录</h2>' + renderPlannerReviewEventsList(task.planner_review_events) + "</div>"; |
| 2021 | 2005 | } |
| 2022 | 2006 | const run = getLatestRunForOrgNode(task, stage); |
| 2023 | 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 | 2009 | return panelHtml |
| 2026 | 2010 | ? panelHtml + renderLoopBackTimelineHtml(run) |
| 2027 | 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 | 2045 | for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) { |
| 2032 | 2046 | el.addEventListener("click", () => { |
| 2033 | 2047 | const stage = el.dataset.metaStage; |
| 2034 | 2048 | const detail = container.querySelector("#org-meta-detail-" + stage); |
| 2035 | 2049 | if (!detail) return; |
| 2036 | 2050 | if (detail.style.display !== "none") { |
| 2037 | - detail.style.display = "none"; | |
| 2038 | - detail.innerHTML = ""; | |
| 2051 | + collapseOrgMetaStage(container, stage); | |
| 2039 | 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 | 2078 | // ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3, |
| 2051 | 2079 | // 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes |
| 2052 | 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 | 59 | .org-node .hint { margin-top: 2px; } |
| 60 | 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 | 67 | .org-strip .org-node[data-org-node] { cursor: pointer; } |
| 66 | 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 | 70 | .org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; } |
| 69 | 71 | @keyframes org-highlight-flash-kf { |
| 70 | 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, "app.js"), "utf8"); |
| 58 | 58 | // `function ...` declarations at top level become sandbox globals |
| 59 | 59 | // automatically (Annex B global-object semantics), but top-level `const` |
| 60 | 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 | 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 | 67 | new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); |
| 66 | 68 | |
| 67 | 69 | // ---- computeGraphLayers: still the shared layering logic v_graph_console |
| ... | ... | @@ -228,4 +230,58 @@ new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); |
| 228 | 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 | 287 | console.log("frontend/test_graph_console_smoke.js: all assertions passed"); | ... | ... |