Commit bc3e7f6ae060eac9b55618e983e22af9f27f34d1

Authored by graphlemon
2 parents b14d3848 e5f6315d

graphlemon: merge 0ca7767c-6666-4094-bac1-2bd7829dc501

frontend/app.js
@@ -90,11 +90,13 @@ const routes = { @@ -90,11 +90,13 @@ const routes = {
90 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, 90 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
91 }; 91 };
92 92
93 -// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit  
94 -// (v_skill_audit_log) are the only dynamic routes -- not worth a full  
95 -// path-matching router for two patterns. 93 +// #/tasks/:task_id (v_task_detail), #/skills/:skill_id/audit
  94 +// (v_skill_audit_log), and #/artifacts/:artifact_id (v_artifact_browser,
  95 +// pre-selecting one artifact) are the only dynamic routes -- not worth a
  96 +// full path-matching router for three patterns.
96 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; 97 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
97 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; 98 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
  99 +const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
98 100
99 let currentRouteCleanup = null; 101 let currentRouteCleanup = null;
100 function registerRouteCleanup(fn) { 102 function registerRouteCleanup(fn) {
@@ -113,12 +115,18 @@ function render() { @@ -113,12 +115,18 @@ function render() {
113 if (!route) { 115 if (!route) {
114 const taskMatch = hash.match(TASK_DETAIL_RE); 116 const taskMatch = hash.match(TASK_DETAIL_RE);
115 const skillMatch = hash.match(SKILL_AUDIT_RE); 117 const skillMatch = hash.match(SKILL_AUDIT_RE);
  118 + const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
116 if (taskMatch) { 119 if (taskMatch) {
117 route = { render: renderTaskDetail, mount: mountTaskDetail }; 120 route = { render: renderTaskDetail, mount: mountTaskDetail };
118 routeArgs = [taskMatch[1]]; 121 routeArgs = [taskMatch[1]];
119 } else if (skillMatch) { 122 } else if (skillMatch) {
120 route = { render: renderSkillAuditLog, mount: mountSkillAuditLog }; 123 route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
121 routeArgs = [skillMatch[1]]; 124 routeArgs = [skillMatch[1]];
  125 + } else if (artifactMatch) {
  126 + // Reuses v_artifact_browser itself (same render/mount as "#/artifacts")
  127 + // with the artifact_id pre-selected -- no separate detail view.
  128 + route = { render: renderArtifactBrowser, mount: mountArtifactBrowser };
  129 + routeArgs = [artifactMatch[1]];
122 } 130 }
123 } 131 }
124 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; 132 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
@@ -1344,8 +1352,12 @@ const GC_CODE_ARTIFACT_TYPES = [&quot;code_diff&quot;, &quot;test_report&quot;]; @@ -1344,8 +1352,12 @@ const GC_CODE_ARTIFACT_TYPES = [&quot;code_diff&quot;, &quot;test_report&quot;];
1344 function renderArtifactSummaryList(artifacts, types, emptyText) { 1352 function renderArtifactSummaryList(artifacts, types, emptyText) {
1345 const filtered = artifacts.filter((a) => types.includes(a.artifact_type)).slice(0, 5); 1353 const filtered = artifacts.filter((a) => types.includes(a.artifact_type)).slice(0, 5);
1346 if (filtered.length === 0) return '<div class="hint">' + emptyText + "</div>"; 1354 if (filtered.length === 0) return '<div class="hint">' + emptyText + "</div>";
  1355 + // Each item links to v_artifact_browser (#/artifacts/:artifact_id) --
  1356 + // click handlers are wired in mountArtifactsOverviewPanel after this HTML
  1357 + // is inserted into the DOM. This deliberately reuses the existing
  1358 + // browser/preview view rather than building a second content display.
1347 return '<ul class="simple-list">' + filtered.map((a) => 1359 return '<ul class="simple-list">' + filtered.map((a) =>
1348 - "<li><span>" + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + "</span><span>" 1360 + '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + "</span><span>"
1349 + escapeHtml(a.validation_status) + "</span></li>" 1361 + escapeHtml(a.validation_status) + "</span></li>"
1350 ).join("") + "</ul>"; 1362 ).join("") + "</ul>";
1351 } 1363 }
@@ -1397,6 +1409,15 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas @@ -1397,6 +1409,15 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas
1397 li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId)); 1409 li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId));
1398 } 1410 }
1399 } 1411 }
  1412 +
  1413 + // Doc/code list items: jump to the existing v_artifact_browser detail
  1414 + // view (#/artifacts/:artifact_id) pre-selecting this artifact. "查看全部"
  1415 + // links above stay pointed at unfiltered #/artifacts per the FR-5 note.
  1416 + for (const li of container.querySelectorAll("li[data-artifact-id]")) {
  1417 + li.addEventListener("click", () => {
  1418 + window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);
  1419 + });
  1420 + }
1400 } 1421 }
1401 1422
1402 function renderGraphConsole() { 1423 function renderGraphConsole() {
@@ -1579,14 +1600,18 @@ function renderArtifactBrowser() { @@ -1579,14 +1600,18 @@ function renderArtifactBrowser() {
1579 `; 1600 `;
1580 } 1601 }
1581 1602
1582 -function mountArtifactBrowser() { 1603 +function mountArtifactBrowser(initialArtifactId) {
1583 const projectId = getCurrentProjectId(); 1604 const projectId = getCurrentProjectId();
1584 if (!projectId) return; 1605 if (!projectId) return;
1585 const listEl = document.getElementById("ab_list"); 1606 const listEl = document.getElementById("ab_list");
1586 const previewEl = document.getElementById("ab_preview"); 1607 const previewEl = document.getElementById("ab_preview");
1587 const typeFilterEl = document.getElementById("ab_type_filter"); 1608 const typeFilterEl = document.getElementById("ab_type_filter");
1588 const taskFilterEl = document.getElementById("ab_task_filter"); 1609 const taskFilterEl = document.getElementById("ab_task_filter");
1589 - let selectedArtifactId = null; 1610 + // Seeded from the #/artifacts/:artifact_id route (v_graph_console's
  1611 + // Planner 产出总览 list links here). refreshList()'s existing fallback
  1612 + // below already handles an id that turns out invalid/filtered-out --
  1613 + // falls back to the first artifact in the (possibly filtered) list.
  1614 + let selectedArtifactId = initialArtifactId || null;
1590 1615
1591 fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)) 1616 fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
1592 .then((resp) => resp.json()) 1617 .then((resp) => resp.json())
frontend/test_graph_console_smoke.js
@@ -12,7 +12,12 @@ @@ -12,7 +12,12 @@
12 // calls this via `.map(renderGraphVisualizerPanel)`, which passes the 12 // calls this via `.map(renderGraphVisualizerPanel)`, which passes the
13 // array index as a second argument; that must NOT be misread as an 13 // array index as a second argument; that must NOT be misread as an
14 // options object with enableNodeClick=true) 14 // options object with enableNodeClick=true)
15 -// - renderArtifactSummaryList (FR-5: type grouping/truncation/empty state) 15 +// - renderArtifactSummaryList (FR-5: type grouping/truncation/empty state,
  16 +// plus 2026-08-04's click-through-to-v_artifact_browser fix: each item
  17 +// now carries data-artifact-id)
  18 +// - ARTIFACT_DETAIL_RE (2026-08-04: #/artifacts/:artifact_id route that
  19 +// v_artifact_browser's own mountArtifactBrowser(initialArtifactId) uses
  20 +// to pre-select the clicked artifact -- see render()'s route dispatch)
16 // 21 //
17 // Run: node frontend/test_graph_console_smoke.js 22 // Run: node frontend/test_graph_console_smoke.js
18 23
@@ -50,7 +55,14 @@ const sandbox = { @@ -50,7 +55,14 @@ const sandbox = {
50 vm.createContext(sandbox); 55 vm.createContext(sandbox);
51 56
52 const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); 57 const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
53 -new vm.Script(src, { filename: "app.js" }).runInContext(sandbox); 58 +// `function ...` declarations at top level become sandbox globals
  59 +// automatically (Annex B global-object semantics), but top-level `const`
  60 +// (e.g. ARTIFACT_DETAIL_RE) stays lexically scoped to the script and never
  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
  63 +// declaration style just for testability.
  64 +const augmentedSrc = src + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;\n";
  65 +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
54 66
55 // ---- computeGraphLayers: still the shared layering logic v_graph_console 67 // ---- computeGraphLayers: still the shared layering logic v_graph_console
56 // leans on for FR-2/3, unchanged by this SDD but worth pinning down since 68 // leans on for FR-2/3, unchanged by this SDD but worth pinning down since
@@ -114,9 +126,9 @@ new vm.Script(src, { filename: &quot;app.js&quot; }).runInContext(sandbox); @@ -114,9 +126,9 @@ new vm.Script(src, { filename: &quot;app.js&quot; }).runInContext(sandbox);
114 // ---- renderArtifactSummaryList (FR-5) ---- 126 // ---- renderArtifactSummaryList (FR-5) ----
115 { 127 {
116 const artifacts = [ 128 const artifacts = [
117 - { artifact_type: "brd", file_path: "docs/brd.md", validation_status: "passed" },  
118 - { artifact_type: "code_diff", file_path: "src/x.py", validation_status: "pending" },  
119 - { artifact_type: "sdd", file_path: "docs/sdd.md", validation_status: "passed" }, 129 + { artifact_id: "art-1", artifact_type: "brd", file_path: "docs/brd.md", validation_status: "passed" },
  130 + { artifact_id: "art-2", artifact_type: "code_diff", file_path: "src/x.py", validation_status: "pending" },
  131 + { artifact_id: "art-3", artifact_type: "sdd", file_path: "docs/sdd.md", validation_status: "passed" },
120 ]; 132 ];
121 const docsHtml = sandbox.renderArtifactSummaryList(artifacts, ["brd", "prd", "sdd", "benchmark"], "空"); 133 const docsHtml = sandbox.renderArtifactSummaryList(artifacts, ["brd", "prd", "sdd", "benchmark"], "空");
122 assert.ok(docsHtml.includes("brd") && docsHtml.includes("sdd"), "docs list groups brd+sdd together"); 134 assert.ok(docsHtml.includes("brd") && docsHtml.includes("sdd"), "docs list groups brd+sdd together");
@@ -124,6 +136,23 @@ new vm.Script(src, { filename: &quot;app.js&quot; }).runInContext(sandbox); @@ -124,6 +136,23 @@ new vm.Script(src, { filename: &quot;app.js&quot; }).runInContext(sandbox);
124 136
125 const emptyHtml = sandbox.renderArtifactSummaryList([], ["brd"], "暂无文档类 Artifact"); 137 const emptyHtml = sandbox.renderArtifactSummaryList([], ["brd"], "暂无文档类 Artifact");
126 assert.ok(emptyHtml.includes("暂无文档类 Artifact"), "empty group shows the given empty-state text"); 138 assert.ok(emptyHtml.includes("暂无文档类 Artifact"), "empty group shows the given empty-state text");
  139 +
  140 + // 2026-08-04 fix: each item must carry a real artifact_id so
  141 + // mountArtifactsOverviewPanel can wire a click -> #/artifacts/:id
  142 + // handler (see render()'s ARTIFACT_DETAIL_RE dispatch) instead of the
  143 + // old bare, unclickable <li>.
  144 + assert.ok(docsHtml.includes('data-artifact-id="art-1"'), "brd item carries its real artifact_id");
  145 + assert.ok(docsHtml.includes('data-artifact-id="art-3"'), "sdd item carries its real artifact_id");
  146 +}
  147 +
  148 +// ---- ARTIFACT_DETAIL_RE (2026-08-04 click-through fix) ----
  149 +{
  150 + const match = "#/artifacts/abc-123".match(sandbox.ARTIFACT_DETAIL_RE);
  151 + assert.ok(match, "#/artifacts/:id must match ARTIFACT_DETAIL_RE");
  152 + assert.strictEqual(match[1], "abc-123", "captured group must be the artifact_id");
  153 +
  154 + assert.ok(!"#/artifacts".match(sandbox.ARTIFACT_DETAIL_RE), "bare #/artifacts (unfiltered browser) must not match -- stays a static route");
  155 + assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match");
127 } 156 }
128 157
129 console.log("frontend/test_graph_console_smoke.js: all assertions passed"); 158 console.log("frontend/test_graph_console_smoke.js: all assertions passed");