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)
'
- + 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 '
最新一次 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");