Commit bc3e7f6ae060eac9b55618e983e22af9f27f34d1
graphlemon: merge 0ca7767c-6666-4094-bac1-2bd7829dc501
Showing
2 changed files
with
65 additions
and
11 deletions
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 = ["code_diff", "test_report"]; | @@ -1344,8 +1352,12 @@ const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"]; | ||
| 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: "app.js" }).runInContext(sandbox); | @@ -114,9 +126,9 @@ new vm.Script(src, { filename: "app.js" }).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: "app.js" }).runInContext(sandbox); | @@ -124,6 +136,23 @@ new vm.Script(src, { filename: "app.js" }).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"); |