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