code_diff.patch 8.58 KB
diff --git a/frontend/app.js b/frontend/app.js
index 96ce34b..5a7da1d 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -90,11 +90,13 @@ const routes = {
   "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
 };
 
-// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit
-// (v_skill_audit_log) are the only dynamic routes -- not worth a full
-// path-matching router for two patterns.
+// #/tasks/:task_id (v_task_detail), #/skills/:skill_id/audit
+// (v_skill_audit_log), and #/artifacts/:artifact_id (v_artifact_browser,
+// pre-selecting one artifact) are the only dynamic routes -- not worth a
+// full path-matching router for three patterns.
 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
+const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
 
 let currentRouteCleanup = null;
 function registerRouteCleanup(fn) {
@@ -113,12 +115,18 @@ function render() {
   if (!route) {
     const taskMatch = hash.match(TASK_DETAIL_RE);
     const skillMatch = hash.match(SKILL_AUDIT_RE);
+    const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
     if (taskMatch) {
       route = { render: renderTaskDetail, mount: mountTaskDetail };
       routeArgs = [taskMatch[1]];
     } else if (skillMatch) {
       route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
       routeArgs = [skillMatch[1]];
+    } else if (artifactMatch) {
+      // Reuses v_artifact_browser itself (same render/mount as "#/artifacts")
+      // with the artifact_id pre-selected -- no separate detail view.
+      route = { render: renderArtifactBrowser, mount: mountArtifactBrowser };
+      routeArgs = [artifactMatch[1]];
     }
   }
   if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
@@ -1344,8 +1352,12 @@ const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"];
 function renderArtifactSummaryList(artifacts, types, emptyText) {
   const filtered = artifacts.filter((a) => types.includes(a.artifact_type)).slice(0, 5);
   if (filtered.length === 0) return '<div class="hint">' + emptyText + "</div>";
+  // Each item links to v_artifact_browser (#/artifacts/:artifact_id) --
+  // click handlers are wired in mountArtifactsOverviewPanel after this HTML
+  // is inserted into the DOM. This deliberately reuses the existing
+  // browser/preview view rather than building a second content display.
   return '<ul class="simple-list">' + filtered.map((a) =>
-    "<li><span>" + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + "</span><span>"
+    '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + "</span><span>"
     + escapeHtml(a.validation_status) + "</span></li>"
   ).join("") + "</ul>";
 }
@@ -1397,6 +1409,15 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas
       li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId));
     }
   }
+
+  // Doc/code list items: jump to the existing v_artifact_browser detail
+  // view (#/artifacts/:artifact_id) pre-selecting this artifact. "查看全部"
+  // links above stay pointed at unfiltered #/artifacts per the FR-5 note.
+  for (const li of container.querySelectorAll("li[data-artifact-id]")) {
+    li.addEventListener("click", () => {
+      window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId);
+    });
+  }
 }
 
 function renderGraphConsole() {
@@ -1579,14 +1600,18 @@ function renderArtifactBrowser() {
   `;
 }
 
-function mountArtifactBrowser() {
+function mountArtifactBrowser(initialArtifactId) {
   const projectId = getCurrentProjectId();
   if (!projectId) return;
   const listEl = document.getElementById("ab_list");
   const previewEl = document.getElementById("ab_preview");
   const typeFilterEl = document.getElementById("ab_type_filter");
   const taskFilterEl = document.getElementById("ab_task_filter");
-  let selectedArtifactId = null;
+  // Seeded from the #/artifacts/:artifact_id route (v_graph_console's
+  // Planner 产出总览 list links here). refreshList()'s existing fallback
+  // below already handles an id that turns out invalid/filtered-out --
+  // falls back to the first artifact in the (possibly filtered) list.
+  let selectedArtifactId = initialArtifactId || null;
 
   fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
     .then((resp) => resp.json())
diff --git a/frontend/test_graph_console_smoke.js b/frontend/test_graph_console_smoke.js
index 55c29eb..6d79da1 100644
--- a/frontend/test_graph_console_smoke.js
+++ b/frontend/test_graph_console_smoke.js
@@ -12,7 +12,12 @@
 //     calls this via `.map(renderGraphVisualizerPanel)`, which passes the
 //     array index as a second argument; that must NOT be misread as an
 //     options object with enableNodeClick=true)
-//   - renderArtifactSummaryList (FR-5: type grouping/truncation/empty state)
+//   - renderArtifactSummaryList (FR-5: type grouping/truncation/empty state,
+//     plus 2026-08-04's click-through-to-v_artifact_browser fix: each item
+//     now carries data-artifact-id)
+//   - ARTIFACT_DETAIL_RE (2026-08-04: #/artifacts/:artifact_id route that
+//     v_artifact_browser's own mountArtifactBrowser(initialArtifactId) uses
+//     to pre-select the clicked artifact -- see render()'s route dispatch)
 //
 // Run: node frontend/test_graph_console_smoke.js
 
@@ -50,7 +55,14 @@ const sandbox = {
 vm.createContext(sandbox);
 
 const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
-new vm.Script(src, { filename: "app.js" }).runInContext(sandbox);
+// `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
+// declaration style just for testability.
+const augmentedSrc = src + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;\n";
+new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
 
 // ---- computeGraphLayers: still the shared layering logic v_graph_console
 // leans on for FR-2/3, unchanged by this SDD but worth pinning down since
@@ -114,9 +126,9 @@ new vm.Script(src, { filename: "app.js" }).runInContext(sandbox);
 // ---- renderArtifactSummaryList (FR-5) ----
 {
   const artifacts = [
-    { artifact_type: "brd", file_path: "docs/brd.md", validation_status: "passed" },
-    { artifact_type: "code_diff", file_path: "src/x.py", validation_status: "pending" },
-    { artifact_type: "sdd", file_path: "docs/sdd.md", validation_status: "passed" },
+    { artifact_id: "art-1", artifact_type: "brd", file_path: "docs/brd.md", validation_status: "passed" },
+    { artifact_id: "art-2", artifact_type: "code_diff", file_path: "src/x.py", validation_status: "pending" },
+    { artifact_id: "art-3", artifact_type: "sdd", file_path: "docs/sdd.md", validation_status: "passed" },
   ];
   const docsHtml = sandbox.renderArtifactSummaryList(artifacts, ["brd", "prd", "sdd", "benchmark"], "空");
   assert.ok(docsHtml.includes("brd") && docsHtml.includes("sdd"), "docs list groups brd+sdd together");
@@ -124,6 +136,23 @@ new vm.Script(src, { filename: "app.js" }).runInContext(sandbox);
 
   const emptyHtml = sandbox.renderArtifactSummaryList([], ["brd"], "暂无文档类 Artifact");
   assert.ok(emptyHtml.includes("暂无文档类 Artifact"), "empty group shows the given empty-state text");
+
+  // 2026-08-04 fix: each item must carry a real artifact_id so
+  // mountArtifactsOverviewPanel can wire a click -> #/artifacts/:id
+  // handler (see render()'s ARTIFACT_DETAIL_RE dispatch) instead of the
+  // old bare, unclickable <li>.
+  assert.ok(docsHtml.includes('data-artifact-id="art-1"'), "brd item carries its real artifact_id");
+  assert.ok(docsHtml.includes('data-artifact-id="art-3"'), "sdd item carries its real artifact_id");
+}
+
+// ---- ARTIFACT_DETAIL_RE (2026-08-04 click-through fix) ----
+{
+  const match = "#/artifacts/abc-123".match(sandbox.ARTIFACT_DETAIL_RE);
+  assert.ok(match, "#/artifacts/:id must match ARTIFACT_DETAIL_RE");
+  assert.strictEqual(match[1], "abc-123", "captured group must be the artifact_id");
+
+  assert.ok(!"#/artifacts".match(sandbox.ARTIFACT_DETAIL_RE), "bare #/artifacts (unfiltered browser) must not match -- stays a static route");
+  assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match");
 }
 
 console.log("frontend/test_graph_console_smoke.js: all assertions passed");