From e9bb6feb1eef1790706bbb98896c68b0e1ded1fe Mon Sep 17 00:00:00 2001 From: bboonnee Date: Thu, 13 Aug 2026 16:56:51 +0800 Subject: [PATCH] refactor: reuse v_graph_console's Org meta-graph in v_task_detail --- frontend/app.js | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------ frontend/style.css | 10 ++++++---- frontend/test_graph_console_smoke.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 225 insertions(+), 139 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index ae88ca3..a6a3f81 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -97,13 +97,17 @@ const routes = { const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; -// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and -// v_task_detail's org-strip nodes route here. `:org_node` is restricted to -// the three real values on purpose -- anything else (typo, stray path -// segment) falls through to the "Not found" fallback in render() instead of -// reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would -// otherwise need its own defensive checks further down. -const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; +// Org Graph 三节点条点击可达详情 (2026-08-06 SDD, extended 2026-08-13 to 4 +// values): v_dashboard's 3-node org-strip and v_task_detail's Org meta-graph +// (now reused from v_graph_console, see mountTaskDetail below) both route +// here. `:org_node` is restricted to the four real values on purpose -- +// anything else (typo, stray path segment) falls through to the "Not found" +// fallback in render() instead of reaching renderTaskDetail/mountTaskDetail +// with a bogus org_node that would otherwise need its own defensive checks +// further down. `planner_reviewer` was added so deep links into the 4th Org +// meta-graph stage are reachable, even though v_dashboard's own org-strip +// stays 3-node (planner/coder/validator only) and never links to it. +const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|planner_reviewer|coder|validator)$/; // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) // previously had nowhere real to send a click -- it just called // onSwitchToTask to switch v_graph_console's own task selector. This is the @@ -722,17 +726,6 @@ function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { }).join(""); } -// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint -// split), `activeSet` already carries different semantics than the -// dashboard's single activeOrgNode -- kept as its own function rather than -// forcing the two call sites to share one shape. -function renderTaskDetailOrgStripHtml(activeSet) { - return ORG_NODE_ORDER.map((node, i) => { - const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; - return '
' + node + "
" + arrow; - }).join(""); -} - function mountOrgStripClicks(container, taskId) { if (!taskId) return; for (const el of container.querySelectorAll(".org-node[data-org-node]")) { @@ -742,72 +735,6 @@ function mountOrgStripClicks(container, taskId) { } } -// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the -// artifact list's own markup pattern (li[data-artifact-id], same as -// renderArtifactSummaryList) filtered down to this run's own artifacts -- -// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact -// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here -// -- they're already rendered once, further down this same page, by the -// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function -// only describes which run that is and lists its artifacts. -function renderOrgComboPanelContent(task, orgNode) { - const run = getLatestRunForOrgNode(task, orgNode); - if (!run) { - return '
' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)
"; - } - const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0; - const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id); - const artifactsHtml = artifacts.length ? ( - '" - ) : '
暂无 Artifact
'; - return ( - '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")" - + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "
" - + '

该 run 产出的 Artifact

' - + artifactsHtml - ); -} - -function wireOrgComboArtifactClicks(scopeEl) { - const combo = scopeEl.querySelector("#org_combo_content"); - if (!combo) return; - for (const li of combo.querySelectorAll("li[data-artifact-id]")) { - li.addEventListener("click", () => { - window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); - }); - } -} - -// Called both on initial mount (deep link / dashboard click landed here with -// an org_node already known) and from the inline org-strip's own click -// handler (already on this page -- 02-V happy path 2.2 explicitly wants an -// in-page scroll, not another full page render). Scrolls to the DAG panel -// first (renders whenever graph_manifest exists), falls back to the to-do -// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成 -// Work Graph" empty states -- never throws if a target is missing. -function focusOrgNodePanel(content, task, orgNode) { - for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { - el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode); - } - const comboContent = content.querySelector("#org_combo_content"); - if (comboContent) { - comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode); - wireOrgComboArtifactClicks(content); - } - const run = getLatestRunForOrgNode(task, orgNode); - let target = run ? document.getElementById("graph-panel-" + run.run_id) : null; - if (!target && run) target = document.getElementById("todo-tree-" + run.run_id); - if (!target) target = content.querySelector("#org_combo_panel"); - if (target && target.scrollIntoView) { - target.scrollIntoView({ behavior: "smooth", block: "start" }); - target.classList.add("org-highlight-flash"); - setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); - } -} - function renderDashboard() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return ` @@ -1217,9 +1144,10 @@ function renderGraphVisualizerPanel(run, options) { }))); return ( - // id="graph-panel-": Org Graph 点击可达详情's scroll/highlight - // target (focusOrgNodePanel above) -- additive, doesn't change any - // existing class/data-* this function already emitted. + // id="graph-panel-": a stable per-run anchor (e.g. v_task_board's + // to-do tree "同一份 graph_manifest 的另一种呈现" cross-reference) -- + // additive, doesn't change any existing class/data-* this function + // already emitted. '

' + orgNodesLabel(run) + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")

" + '
' + '' @@ -1426,12 +1354,13 @@ function mountTaskDetail(taskId, initialOrgNode) { if (!projectId) return; const content = document.getElementById("td_content"); - // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's - // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the + // Org Graph 三节点条点击可达详情 (2026-08-06 SDD, reuses v_graph_console's + // Org meta-graph as of 2026-08-13 -- see mountOrgMetaGraph's comment): + // which stage's panel is currently focused -- seeded from the // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and - // updated in place when the inline org-strip below is clicked while - // already on this page, so a WS-triggered refresh() re-render keeps - // pointing at the same node instead of losing focus. + // updated in place when the meta-graph below is clicked while already on + // this page, so a WS-triggered refresh() re-render keeps pointing at the + // same stage instead of losing focus. let focusedOrgNode = initialOrgNode || null; // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do @@ -1443,6 +1372,49 @@ function mountTaskDetail(taskId, initialOrgNode) { activeLogViewers.clear(); } + // Wires the Org meta-graph's own work-node click-to-expand (enableNodeClick + // opt-in below, mirrors mountGraphConsole's onWorkNodeClick) into this + // page's own activeLogViewers/mountLogViewer machinery instead of a + // page-local one, so a WS-triggered refresh()/route change closes these + // sockets the same way it already does for the to-do tree's log viewers. + // + // ponytail: activeLogViewers is keyed by node_exec_id only, and both the + // to-do tree (below) and the meta-graph's expanded panels can show the + // very same node_exec_id (a Phase 2c merged run can be "the latest run" + // for more than one org_node, and to-do-tree/meta-graph both render off + // the same task.work_graph_runs). If a node's log is already open in one + // location, clicking it in the other doesn't spin up a second competing + // c_ws_log_tail connection or a duplicate `#log-viewer-` DOM id (that + // would be a real bug) -- it just scrolls to the already-open one instead + // of also mounting a second copy there. Ceiling: the second location shows + // an empty slot until the first is closed, rather than a live mirrored + // view. Upgrade path if that turns out to matter in practice: a + // location-aware key (e.g. `locationId + ":" + nodeExecId`) with each + // entry recording which slot currently owns the DOM, so either location's + // click can steal/re-home it instead of just deflecting. + function onOrgMetaWorkNodeClick(nodeExecId, status, nodeEl) { + const slot = nodeEl.querySelector(".log-viewer-slot"); + if (!slot) return; + if (slot.querySelector(".log-viewer")) { + // This slot owns the mounted viewer -- close it for real. + if (activeLogViewers.has(nodeExecId)) { + activeLogViewers.get(nodeExecId)(); + activeLogViewers.delete(nodeExecId); + } + slot.innerHTML = ""; + nodeEl.classList.remove("expanded"); + return; + } + if (activeLogViewers.has(nodeExecId)) { + const existing = document.getElementById("log-viewer-" + nodeExecId); + if (existing && existing.scrollIntoView) existing.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + slot.innerHTML = '
' + renderLogViewerHtml(nodeExecId) + "
"; + activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, status)); + nodeEl.classList.add("expanded"); + } + function renderTodoTree(run) { if (!run.graph_manifest) return ""; const nodesById = {}; @@ -1478,13 +1450,6 @@ function mountTaskDetail(taskId, initialOrgNode) { function renderDetail(task) { closeAllLogViewers(); - const activeSet = new Set([task.assigned_org_node]); - for (const run of task.work_graph_runs) { - if (run.status !== "done" && run.status !== "failed") { - for (const orgNode of run.org_nodes_involved || []) activeSet.add(orgNode); - } - } - const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet); // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one // real thing "running" against it at a time -- exactly one of these @@ -1566,14 +1531,13 @@ function mountTaskDetail(taskId, initialOrgNode) { + "
ASSIGNED_ORG_NODE" + task.assigned_org_node + "
" + "
CREATED_VIA" + escapeHtml(task.created_via) + "
" + "
" - + '

Org Graph

' + orgStripHtml + "
" - // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by - // focusOrgNodePanel() below, either right after this render (deep - // link/dashboard click already carries an org_node) or on a later - // inline org-strip click. Left as the default hint until then. - + '

Org 节点详情

' - + '
点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact
' - + "
" + // Org Graph panel (2026-08-13): reuses v_graph_console's 4-stage Org + // meta-graph (renderOrgMetaGraph/mountOrgMetaGraph, planner -> + // planner_reviewer -> coder -> validator) instead of this page's old + // 3-node org-strip + separate "Org 节点详情" combo panel -- gains + // planner_reviewer visibility for free. Filled in and wired up right + // after this innerHTML assignment (needs a real container element). + + '

Org Graph

' + todoTreesHtml + graphVisualizerHtml + '
' @@ -1598,21 +1562,36 @@ function mountTaskDetail(taskId, initialOrgNode) { mountGraphVisualizerTooltips(content); mountGraphVisualizerEdges(content); - // Org Graph 三节点条点击可达详情: clicking a node here (already on - // v_task_detail) stays on this page -- update focusedOrgNode + the URL - // (history.replaceState, no hashchange -> no full route re-render) and - // scroll/highlight in place, per 02-V happy path 2.2. - for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { - el.addEventListener("click", () => { - focusedOrgNode = el.dataset.orgNode; + // Org Graph panel: render + wire the reused Org meta-graph. + // enableNodeClick:true + onNodeClick opts into real per-node live + // log-tail expansion via this page's own machinery above -- + // v_graph_console's own usage of mountOrgMetaGraph passes no options at + // all, so it stays enableNodeClick:false/read-only, unaffected by this. + // onStageExpand mirrors what the old org-strip's click handler used to + // do: update focusedOrgNode + the URL (history.replaceState, no + // hashchange -> no full route re-render) and the focused highlight, per + // 02-V happy path 2.2 -- just without a scroll, since the panel now + // expands in place right where the click happened instead of pointing + // at a separate DAG section further down the page. + const orgMetaGraphEl = content.querySelector("#td_org_meta_graph"); + orgMetaGraphEl.innerHTML = renderOrgMetaGraph(task); + const orgMetaGraphOptions = { + enableNodeClick: true, + onNodeClick: onOrgMetaWorkNodeClick, + onStageExpand: (stage) => { + focusedOrgNode = stage; const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; if (window.location.hash !== newHash) history.replaceState(null, "", newHash); - focusOrgNodePanel(content, task, focusedOrgNode); - }); - } + for (const el of orgMetaGraphEl.querySelectorAll(".graph-topo-node[data-meta-stage]")) { + el.classList.toggle("org-node-focused", el.dataset.metaStage === focusedOrgNode); + } + }, + }; + mountOrgMetaGraph(orgMetaGraphEl, task, orgMetaGraphOptions); // Deep link / dashboard click landed here with an org_node already - // known -- populate + scroll to it once, right after this render. - if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode); + // known -- expand + scroll/highlight it once, right after this render + // (equivalent to the old focusOrgNodePanel()). + if (focusedOrgNode) focusOrgMetaStage(orgMetaGraphEl, task, focusedOrgNode, orgMetaGraphOptions); const resumeBtn = document.getElementById("btn_manual_resume"); if (resumeBtn) { @@ -2012,41 +1991,90 @@ function renderPlannerReviewEventsList(events) { ).join("") + ""; } -// planner/coder/validator branch mirrors renderOrgComboPanelContent's own -// empty-run/empty-DAG fallback text -- same two states, this is just the -// meta-graph's own expand target rather than v_task_detail's combo panel. -function renderOrgMetaStageDetailHtml(task, stage) { +// planner/coder/validator branch renders the same empty-run/empty-DAG +// fallback text v_task_detail's old combo panel used to. `enableNodeClick` +// (2026-08-13, Org Graph unification/console reuse into v_task_detail) is +// threaded straight into renderGraphVisualizerPanel's own existing options +// param -- v_graph_console's call sites never pass it (stays `undefined` -> +// falsy -> `{ enableNodeClick: false }`, byte-for-byte what this already +// rendered), while v_task_detail's usage below opts in for real per-node +// live log-tail expansion instead of the read-only DAG snapshot. +function renderOrgMetaStageDetailHtml(task, stage, enableNodeClick) { if (stage === "planner_reviewer") { return '

Planner 产出复核记录

' + renderPlannerReviewEventsList(task.planner_review_events) + "
"; } const run = getLatestRunForOrgNode(task, stage); if (!run) return '
' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)
"; - const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: false }); + const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: !!enableNodeClick }); return panelHtml ? panelHtml + renderLoopBackTimelineHtml(run) : '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph
"; } -function mountOrgMetaGraph(container, task) { +// `options` (2026-08-13, v_task_detail reuse): `{ enableNodeClick, onNodeClick, +// onStageExpand }`, all optional -- v_graph_console's existing call +// (`mountOrgMetaGraph(orgMetaGraphEl, task)`, no third arg) leaves every one +// of them `undefined`, so `enableNodeClick` stays falsy (read-only DAG +// snapshot, unchanged) and `mountWorkNodeClickExpand`/`onStageExpand` are +// never invoked -- v_graph_console's behavior is untouched by this refactor. +// v_task_detail passes `enableNodeClick: true` + a real `onNodeClick` wired +// to its own `activeLogViewers` map (mountTaskDetail below) for real +// per-node live log-tail expansion, and `onStageExpand` to keep the deep-link +// URL/focus state in sync when a stage is opened by a direct click (not just +// the initial deep-link landing, which calls expandOrgMetaStage directly). +function expandOrgMetaStage(container, task, stage, options) { + const detail = container.querySelector("#org-meta-detail-" + stage); + if (!detail) return; + const enableNodeClick = options && options.enableNodeClick; + detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage, enableNodeClick); + detail.style.display = "block"; + mountGraphVisualizerTooltips(detail); + mountGraphVisualizerEdges(detail); + mountLoopBackTimeline(detail); + if (enableNodeClick) mountWorkNodeClickExpand(detail, { onNodeClick: options.onNodeClick }); +} + +function collapseOrgMetaStage(container, stage) { + const detail = container.querySelector("#org-meta-detail-" + stage); + if (!detail) return; + detail.style.display = "none"; + detail.innerHTML = ""; +} + +function mountOrgMetaGraph(container, task, options) { for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) { el.addEventListener("click", () => { const stage = el.dataset.metaStage; const detail = container.querySelector("#org-meta-detail-" + stage); if (!detail) return; if (detail.style.display !== "none") { - detail.style.display = "none"; - detail.innerHTML = ""; + collapseOrgMetaStage(container, stage); return; } - detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage); - detail.style.display = "block"; - mountGraphVisualizerTooltips(detail); - mountGraphVisualizerEdges(detail); - mountLoopBackTimeline(detail); + expandOrgMetaStage(container, task, stage, options); + if (options && options.onStageExpand) options.onStageExpand(stage); }); } } +// Deep-link / focus entry point (2026-08-13, replaces the old +// focusOrgNodePanel): called on initial mount when the route already carries +// an org_node (#/tasks/:task_id/org/:org_node) -- expands that stage's panel +// and scrolls/highlights it into view, equivalent to what focusOrgNodePanel +// used to do for the old inline org-strip + combo panel. +function focusOrgMetaStage(container, task, stage, options) { + for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) { + el.classList.toggle("org-node-focused", el.dataset.metaStage === stage); + } + expandOrgMetaStage(container, task, stage, options); + const target = container.querySelector("#org-meta-detail-" + stage); + if (target && target.scrollIntoView) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + target.classList.add("org-highlight-flash"); + setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); + } +} + // ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3, // 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes // run.loop_back_events (source_node_id/target_node_id/attempt_number/ diff --git a/frontend/style.css b/frontend/style.css index 06b424b..1ff29f6 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -59,12 +59,14 @@ body { font-family: system-ui, sans-serif; margin: 0; } .org-node .hint { margin-top: 2px; } .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; } -/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to - `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in - place (v_task_detail's own inline org-strip). */ +/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): v_dashboard's org-strip + nodes route to `#/tasks/:task_id/org/:org_node`. v_task_detail itself + switched to the (4-stage) Org meta-graph below on 2026-08-13 -- its nodes + are `.graph-topo-node`, not `.org-node`, so `.org-node-focused` is kept + unscoped rather than duplicated per-component. */ .org-strip .org-node[data-org-node] { cursor: pointer; } .org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; } -.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; } +.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; } .org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; } @keyframes org-highlight-flash-kf { 0% { box-shadow: 0 0 0 3px #f59e0b; } diff --git a/frontend/test_graph_console_smoke.js b/frontend/test_graph_console_smoke.js index 12772ef..78e6497 100644 --- a/frontend/test_graph_console_smoke.js +++ b/frontend/test_graph_console_smoke.js @@ -58,10 +58,12 @@ const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); // `function ...` declarations at top level become sandbox globals // automatically (Annex B global-object semantics), but top-level `const` // (e.g. ARTIFACT_DETAIL_RE) stays lexically scoped to the script and never -// reaches the sandbox object -- explicitly re-export the one const this -// test needs, in the same script/lexical scope, rather than changing app.js's +// reaches the sandbox object -- explicitly re-export the consts this test +// needs, in the same script/lexical scope, rather than changing app.js's // declaration style just for testability. -const augmentedSrc = src + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;\n"; +const augmentedSrc = src + + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;" + + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n"; new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); // ---- computeGraphLayers: still the shared layering logic v_graph_console @@ -228,4 +230,58 @@ new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match"); } +// ---- ORG_NODE_DETAIL_RE (2026-08-13: v_task_detail reuses the Org +// meta-graph, extending the route's `:org_node` capture group from 3 values +// to 4 so `#/tasks/:id/org/planner_reviewer` deep links are reachable) ---- +{ + const match = "#/tasks/task-1/org/planner_reviewer".match(sandbox.ORG_NODE_DETAIL_RE); + assert.ok(match, "planner_reviewer must now match ORG_NODE_DETAIL_RE"); + assert.strictEqual(match[1], "task-1", "captured task_id must be correct"); + assert.strictEqual(match[2], "planner_reviewer", "captured org_node must be planner_reviewer"); + + for (const node of ["planner", "coder", "validator"]) { + assert.ok(("#/tasks/task-1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), node + " must still match (pre-existing 3 values)"); + } + 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"); +} + +// ---- renderOrgMetaStageDetailHtml (2026-08-13: v_task_detail reuse of the +// Org meta-graph): `enableNodeClick` is a new 3rd param threaded straight +// into renderGraphVisualizerPanel's own options -- the actual regression +// risk flagged for this change is v_graph_console's existing call sites +// (which never pass a 3rd arg) silently starting to render clickable nodes +// too. Pin both sides: omitted/false stays exactly what v_graph_console +// already rendered; `true` (v_task_detail's new opt-in) really does add the +// click-to-expand markup. ---- +{ + const task = { + planner_review_events: [], + work_graph_runs: [{ + run_id: "run-meta-1", status: "running", org_nodes_involved: ["coder"], + graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] }, + nodes: [{ node_id: "n1", node_exec_id: "exec-meta-1", subagent_type: "coder", status: "running" }], + }], + }; + + const readOnlyHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder"); + assert.ok(!readOnlyHtml.includes("clickable"), "v_graph_console's call (no 3rd arg) must stay read-only, no clickable class"); + assert.ok(!readOnlyHtml.includes("data-clickable-node-exec-id"), "v_graph_console's call must not add the click-target id"); + assert.ok(!readOnlyHtml.includes("log-viewer-slot"), "v_graph_console's call must not reserve a log-viewer-slot"); + + const explicitFalseHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder", false); + assert.ok(!explicitFalseHtml.includes("clickable"), "an explicit false must also stay read-only"); + + const clickableHtml = sandbox.renderOrgMetaStageDetailHtml(task, "coder", true); + assert.ok(clickableHtml.includes("work-node status-running clickable"), "v_task_detail's opt-in (3rd arg true) must add the clickable class"); + 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"); + assert.ok(clickableHtml.includes('
'), "v_task_detail's opt-in must reserve a log-viewer-slot for mountWorkNodeClickExpand"); + + // planner_reviewer never has a Work Graph run (it's a flat verdict trail, + // not a self-planned graph) -- enableNodeClick is meaningless there and + // must not throw or leak into the output. + const reviewerHtml = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer", true); + assert.ok(reviewerHtml.includes("Planner 产出复核记录"), "planner_reviewer branch renders its own verdict-trail panel regardless of enableNodeClick"); + assert.ok(!reviewerHtml.includes("clickable"), "planner_reviewer branch never renders DAG markup, so no clickable class either"); +} + console.log("frontend/test_graph_console_smoke.js: all assertions passed"); -- libgit2 0.21.4