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: () => "
Not found
" + hash + "
" };
@@ -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 '' + emptyText + "
";
+ // 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 '' + filtered.map((a) =>
- "- " + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + ""
+ '
- ' + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + ""
+ escapeHtml(a.validation_status) + "
"
).join("") + "
";
}
@@ -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 .
+ 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");