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 '"; } @@ -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");