feat(desktop): artifact evolution chain + Board/Timeline views

Adds a version-evolution capability to the Artifacts pane:
  - Evolution chain: LCS-based diff across successive versions of the
    same logical artifact; when a blob is missing, the chain honestly
    surfaces a placeholder rather than fabricating diff content.
  - Board / Timeline tab switcher on the Artifacts page.
  - Auto-grouping into 5 kinds so the Board reads at a glance.

Files:
  src/renderer/artifacts-board.js       — new Board / Timeline / Evolution views
  src/renderer/artifacts.js             — wire Board tab + evolution rail
  src/renderer/index.html               — script tag + tab markup
  src/renderer/style.css                — tail append (~200 lines)
  test/artifact-evolution-board.test.js — 23 new tests
  docs/artifact-board-fixture.json      — fixture seed
  docs/qa-artifact-evolution/           — 3 QA screenshots + fixture.html
  scripts/qa-cdp-shoot-artifact-v2.mjs  — QA capture harness

Merged as source-repo test-real @ 7279870 (with lane-side conflict
resolution against test-real @ c1d93aa: style.css tail-append union;
independent of Lanes C/A/D CSS sections).

Test suite: 1727/1727 pass (+23 over Lane C+A+D baseline of 1704).

Follow-up L-3 (in upstream ledger): runtime ArtifactServer to grow
a snapshot store so the "missing blob" fallback becomes rare.
This commit is contained in:
ZiyaZhang
2026-07-19 01:10:53 -07:00
parent 4c47ce55e2
commit c8f5ca3d5b
11 files changed
+1704 -39

No files matched your search

@@ -0,0 +1,86 @@
{
"_note": "Fixture for lane-artifact-v2 (Artifacts evolution chain + Board view). Each entry is one artifact:event payload the renderer would receive. Multi-version entries share an artifactId; blob content is included so the evolution diff pane can render real per-hop line-diffs. The real ArtifactServer does not include content in its broadcast — that's the constraint the strip's fallback 'content not preserved' note is calibrated for.",
"artifacts": [
{
"artifactId": "session.md",
"kind": "md",
"version": 1,
"seenAt": 1747000000000,
"path": "/w/.artifacts/session.md",
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\n"
},
{
"artifactId": "session.md",
"kind": "md",
"version": 2,
"seenAt": 1747000060000,
"path": "/w/.artifacts/session.md",
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\nTurn 2: agent scaffolded landing.html and hero.svg.\n"
},
{
"artifactId": "session.md",
"kind": "md",
"version": 3,
"seenAt": 1747000180000,
"path": "/w/.artifacts/session.md",
"blob": "# Session log\n\nTurn 1: user asked to build a landing page.\nTurn 2: agent scaffolded landing.html and hero.svg.\nTurn 3: agent added responsive styles and OG tags.\nTurn 4: agent added README with build steps.\n"
},
{
"artifactId": "landing.html",
"kind": "html",
"version": 1,
"seenAt": 1747000030000,
"path": "/w/.artifacts/landing.html",
"blob": "<!doctype html>\n<html>\n<head><title>Landing</title></head>\n<body>\n <h1>Hello</h1>\n</body>\n</html>\n"
},
{
"artifactId": "landing.html",
"kind": "html",
"version": 2,
"seenAt": 1747000090000,
"path": "/w/.artifacts/landing.html",
"blob": "<!doctype html>\n<html>\n<head>\n <title>Landing</title>\n <meta name=\"description\" content=\"A demo landing page\">\n</head>\n<body>\n <h1>Hello</h1>\n <p>Welcome to our product.</p>\n</body>\n</html>\n"
},
{
"artifactId": "hero.svg",
"kind": "svg",
"version": 1,
"seenAt": 1747000045000,
"path": "/w/.artifacts/hero.svg",
"blob": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 100\">\n <rect width=\"200\" height=\"100\" fill=\"#eef\"/>\n</svg>\n"
},
{
"artifactId": "config.json",
"kind": "json",
"version": 1,
"seenAt": 1747000120000,
"path": "/w/.artifacts/config.json",
"blob": "{\n \"name\": \"landing\",\n \"version\": \"0.1.0\"\n}\n"
},
{
"artifactId": "config.json",
"kind": "json",
"version": 2,
"seenAt": 1747000200000,
"path": "/w/.artifacts/config.json",
"blob": "{\n \"name\": \"landing\",\n \"version\": \"0.2.0\",\n \"port\": 3000\n}\n"
},
{
"artifactId": "build.sh",
"kind": "sh",
"version": 1,
"seenAt": 1747000150000,
"path": "/w/.artifacts/build.sh",
"blob": "#!/bin/sh\nset -e\nnpm install\nnpm run build\n"
},
{
"artifactId": "README.md",
"kind": "md",
"version": 1,
"seenAt": 1747000210000,
"path": "/w/.artifacts/README.md",
"blob": "# Landing\n\nStatic landing page for the demo product.\n\n## Build\n\n```sh\nsh build.sh\n```\n"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<!-- Reproducible fixture for the lane-artifact-v2 change (version
evolution strip + Board / Timeline views). Loads the fixture data
from docs/artifact-board-fixture.json and replays every entry
through window.__cb (the onArtifact bridge) in order, so:
* Auto-group + L0 rows populate the List view.
* Multi-version entries (session.md v1→v3, landing.html v1→v2,
config.json v1→v2) exercise the evolution strip diffs.
* Every kind bucket (md/html/svg/json/code/other) surfaces in the
Board view.
Screenshots for docs/qa-artifact-evolution/ are taken with the
panel in each of the three views + the evolution strip opened. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>lane-artifact-v2 fixture — Board + evolution</title>
<link rel="stylesheet" href="../../src/renderer/style.css">
<style>
/* Bare-minimum host chrome. */
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.app { display: flex; height: 100vh; }
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.header {
padding: 10px 16px; border-bottom: 1px solid var(--border);
font-size: 13px; color: var(--muted);
display: flex; gap: 8px; align-items: center;
}
.header .fixture-controls button {
background: var(--bg-elev-2); color: var(--text);
border: 1px solid var(--border); padding: 3px 10px;
border-radius: 4px; font-size: 11px; cursor: pointer;
}
.header .fixture-controls button:hover { border-color: var(--accent); }
#stream {
flex: 1; padding: 12px; overflow-y: auto;
display: flex; flex-direction: column; gap: 12px;
}
/* Silence the pulse + arrival flash so screenshots capture the
resting state, matching the qa-artifact-compact convention. */
.artifact-live-dot { animation: none !important; }
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
</style>
</head>
<body>
<div class="app">
<div class="main">
<div class="header">
<span>Fixture: 3 multi-version + 4 single-version artifacts across 5 kinds</span>
<span class="fixture-controls" style="margin-left: auto; display: flex; gap: 6px;">
<button data-view="list">List</button>
<button data-view="board">Board</button>
<button data-view="timeline">Timeline</button>
<button id="open-evolution">Open evolution: session.md</button>
</span>
</div>
<div id="stream" class="stream"></div>
</div>
</div>
<!-- Fake bridge — artifacts.js only touches window.dsh.onArtifact +
window.dsh.openArtifact + window.dsh.mockArtifact. -->
<script>
window.dsh = {
onArtifact: (cb) => { window.__cb = cb },
openArtifact: async () => ({ ok: true }),
mockArtifact: async () => {},
}
</script>
<script src="../../src/renderer/artifacts-board.js"></script>
<script src="../../src/renderer/artifacts.js"></script>
<script>
// Replay the fixture through the same onArtifact seam the real IPC
// event uses. Because the artifacts.js history tracker de-dups on
// version, feeding multi-version entries in order produces exactly
// the same chain a real session would.
async function main() {
const res = await fetch('../artifact-board-fixture.json')
const data = await res.json()
for (const entry of data.artifacts) {
window.__cb(entry)
}
// Wire the header buttons to the exposed switchView.
const api = window.__dshArtifacts
for (const btn of document.querySelectorAll('.fixture-controls button[data-view]')) {
btn.addEventListener('click', () => api.switchView(btn.dataset.view))
}
// Convenience: pop the version chip on session.md so QA can
// snap the evolution strip without a manual click search.
document.getElementById('open-evolution').addEventListener('click', () => {
const card = document.querySelector('.artifact-card[data-artifact-id="session.md"]')
if (!card) return
const chip = card.querySelector('.artifact-version')
if (chip) chip.click()
})
}
main()
</script>
</body>
</html>