Commit e5f6315de9bec7eb3cb957d8a00307812bd59de0

Authored by graphlemon
1 parent b14d3848

graphlemon: auto-commit uncommitted changes for 0ca7767c-6666-4094-bac1-2bd7829dc501

frontend/app.js
... ... @@ -90,11 +90,13 @@ const routes = {
90 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 97 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
97 98 const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
  99 +const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
98 100  
99 101 let currentRouteCleanup = null;
100 102 function registerRouteCleanup(fn) {
... ... @@ -113,12 +115,18 @@ function render() {
113 115 if (!route) {
114 116 const taskMatch = hash.match(TASK_DETAIL_RE);
115 117 const skillMatch = hash.match(SKILL_AUDIT_RE);
  118 + const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
116 119 if (taskMatch) {
117 120 route = { render: renderTaskDetail, mount: mountTaskDetail };
118 121 routeArgs = [taskMatch[1]];
119 122 } else if (skillMatch) {
120 123 route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
121 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 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 1352 function renderArtifactSummaryList(artifacts, types, emptyText) {
1345 1353 const filtered = artifacts.filter((a) => types.includes(a.artifact_type)).slice(0, 5);
1346 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 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 1361 + escapeHtml(a.validation_status) + "</span></li>"
1350 1362 ).join("") + "</ul>";
1351 1363 }
... ... @@ -1397,6 +1409,15 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas
1397 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 1423 function renderGraphConsole() {
... ... @@ -1579,14 +1600,18 @@ function renderArtifactBrowser() {
1579 1600 `;
1580 1601 }
1581 1602  
1582   -function mountArtifactBrowser() {
  1603 +function mountArtifactBrowser(initialArtifactId) {
1583 1604 const projectId = getCurrentProjectId();
1584 1605 if (!projectId) return;
1585 1606 const listEl = document.getElementById("ab_list");
1586 1607 const previewEl = document.getElementById("ab_preview");
1587 1608 const typeFilterEl = document.getElementById("ab_type_filter");
1588 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 1616 fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
1592 1617 .then((resp) => resp.json())
... ...
frontend/test_graph_console_smoke.js
... ... @@ -12,7 +12,12 @@
12 12 // calls this via `.map(renderGraphVisualizerPanel)`, which passes the
13 13 // array index as a second argument; that must NOT be misread as an
14 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 22 // Run: node frontend/test_graph_console_smoke.js
18 23  
... ... @@ -50,7 +55,14 @@ const sandbox = {
50 55 vm.createContext(sandbox);
51 56  
52 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 67 // ---- computeGraphLayers: still the shared layering logic v_graph_console
56 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 126 // ---- renderArtifactSummaryList (FR-5) ----
115 127 {
116 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 133 const docsHtml = sandbox.renderArtifactSummaryList(artifacts, ["brd", "prd", "sdd", "benchmark"], "空");
122 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 136  
125 137 const emptyHtml = sandbox.renderArtifactSummaryList([], ["brd"], "暂无文档类 Artifact");
126 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 158 console.log("frontend/test_graph_console_smoke.js: all assertions passed");
... ...