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

+245 -39
View File
@@ -1,18 +1,29 @@
// Renderer-side artifact card: inline entry point in the chat stream that
// opens the artifact in the system browser. Deliberately not a webview — the
// demo shell only hosts the entry point per the RFC (2026-07-13 §Deliberate
// exclusions, "No embedded GUI pane").
// opens the artifact in the system browser. Deliberately not a webview —
// the demo shell only hosts the entry point per the RFC (2026-07-13
// §Deliberate exclusions, "No embedded GUI pane").
//
// Density-spec §2 L0 shape (user-flagged 2026-07-18): each artifact renders
// as a single ~28px row — small icon + filename + kind/version chips + live
// dot + tiny right-aligned `open ↗` link. Clicking the row toggles a native
// <details> L1 body that carries the full path and the ghost "Open in
// browser" button. Consecutive .artifact-card siblings render as a visual
// group (shared border, zero gap between rows) via CSS `:has()`.
// Density-spec §2 L0 shape (user-flagged 2026-07-18): each artifact
// renders as a single ~28px row — small icon + filename + kind/version
// chips + live dot + tiny right-aligned `open ↗` link. Clicking the row
// toggles a native <details> L1 body that carries the full path and the
// ghost "Open in browser" button. Consecutive .artifact-card siblings
// render as a visual group (shared border, zero gap between rows) via
// CSS `:has()`.
//
// V2 (lane-artifact-v2, 2026-07-19): the group container grew a top
// tab-bar (List / Board / Timeline) so the same artifact stream can be
// viewed three ways without leaving the chat. Clicking the L0
// `.artifact-version` chip on any List row expands an inline evolution
// chain (chain rendered by artifacts-board.js). History is kept per
// artifactId as versions arrive; blob content is captured when supplied
// by the event so the fixture demo can render real per-hop diffs — for
// the real runtime the pre-latest blobs aren't preserved, so the diff
// panes show an honest "content not preserved" note there.
//
// Two triggers:
// 1. tool/result carrying a file write inside the artifact dir (detected
// by main.js and re-broadcast as `artifact:event`).
// 1. tool/result carrying a file write inside the artifact dir
// (detected by main.js and re-broadcast as `artifact:event`).
// 2. debug menu "mock: artifact" button (window.dsh.mockArtifact).
//
// De-dup: one card per artifactId per stream. If a re-declare fires the
@@ -25,11 +36,21 @@
// artifactId -> DOM element, so a same-path re-declare updates in place.
const cards = new Map()
// artifactId -> [{ version, seenAt, kind, path, blob? }, …] — populated
// as `artifact:event` fires. Fed to artifacts-board.js for Board/
// Timeline/Evolution renderers.
const history = new Map()
// The tab-bar container ("Artifact panel") wraps every artifact-group
// so the List / Board / Timeline tabs sit above the same event stream.
// Kept as a single, top-of-stream panel — the compact L0 rows still
// live inside it under the List view.
let panelEl = null
let currentView = 'list'
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke) so
// artifact rows match the minimalist icon language rather than sitting
// on emoji glyphs. Fallback is the paperclip glyph used elsewhere for
// context-family cards.
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke)
// so artifact rows match the minimalist icon language rather than
// sitting on emoji glyphs. Fallback is the paperclip glyph used
// elsewhere for context-family cards.
const ICON_SVG = {
html:
'<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">'
@@ -59,16 +80,50 @@
if (s) s.scrollTop = s.scrollHeight
}
function recordHistory(entry) {
const id = entry.artifactId
const version = entry.version || 1
const rec = {
artifactId: id,
version,
seenAt: entry.seenAt || Date.now(),
kind: entry.kind,
path: entry.path,
}
// Fixture / server-side blob capture. The real ArtifactServer does
// not include content in its `artifact:event` payload; the fixture
// does. When present we retain it so the evolution diff panes can
// render real per-hop line diffs.
if (typeof entry.blob === 'string') rec.blob = entry.blob
const arr = history.get(id) || []
// De-dup on version — a re-broadcast of the same version shouldn't
// double-count in the timeline. Latest wins for the seenAt/blob
// fields so a corrected blob overwrites the placeholder.
const idx = arr.findIndex((r) => r.version === version)
if (idx >= 0) arr[idx] = { ...arr[idx], ...rec }
else arr.push(rec)
history.set(id, arr)
}
function ensureCard(entry) {
recordHistory(entry)
const existing = cards.get(entry.artifactId)
if (existing) {
updateCard(existing, entry)
// If the evolution strip for this card is expanded, refresh it so
// the new version appears in the chain without a manual re-click.
refreshEvolutionIfOpen(existing, entry)
// View-level projections re-render on demand — the Board / Timeline
// views read live state on switch, so a dropped-in event during
// those views repaints the panel body.
if (currentView !== 'list') refreshView()
return existing
}
const el = renderCard(entry)
cards.set(entry.artifactId, el)
const s = streamEl()
if (s) appendGrouped(s, el)
if (currentView !== 'list') refreshView()
scrollToBottom()
return el
}
@@ -77,23 +132,114 @@
// the list reads as one clumped block. The stream itself has a 12px
// flex `gap` that a plain negative margin can't undo; the wrapper owns
// its own zero-gap layout so grouped rows sit flush.
//
// V2 note: the group itself lives inside `.artifact-panel` — a single
// container above the stream position where the first artifact would
// land, hosting the List/Board/Timeline tab bar. All subsequent
// artifacts append into the same group so the tab-bar covers one
// coherent event stream per session.
function appendGrouped(stream, el) {
const last = stream.lastElementChild
if (last && last.classList && last.classList.contains('artifact-group')) {
last.appendChild(el)
// First artifact of the session: build the panel + tab bar and
// append it to the stream. The panel owns a `.artifact-group` in
// its body which the List view uses as-is.
if (!panelEl || !panelEl.isConnected) {
panelEl = buildPanel()
stream.appendChild(panelEl)
}
const groupHost = panelEl.querySelector('.artifact-group')
groupHost.appendChild(el)
}
function buildPanel() {
const panel = document.createElement('div')
panel.className = 'artifact-panel'
panel.dataset.view = 'list'
const tabBar = document.createElement('div')
tabBar.className = 'artifact-panel-tabs'
tabBar.setAttribute('role', 'tablist')
for (const v of ['list', 'board', 'timeline']) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'artifact-panel-tab'
btn.dataset.view = v
btn.setAttribute('role', 'tab')
btn.setAttribute('aria-selected', v === 'list' ? 'true' : 'false')
btn.textContent = v[0].toUpperCase() + v.slice(1)
btn.addEventListener('click', () => switchView(v))
tabBar.appendChild(btn)
}
panel.appendChild(tabBar)
const body = document.createElement('div')
body.className = 'artifact-panel-body'
// The List view surface — the auto-grouped rows continue to render
// here directly, so downstream QA that inspects `.artifact-group`
// keeps working unchanged.
const group = document.createElement('div')
group.className = 'artifact-group'
body.appendChild(group)
panel.appendChild(body)
return panel
}
function switchView(v) {
if (v === currentView) return
currentView = v
if (!panelEl) return
panelEl.dataset.view = v
for (const tab of panelEl.querySelectorAll('.artifact-panel-tab')) {
tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false')
}
refreshView()
}
function refreshView() {
if (!panelEl) return
const body = panelEl.querySelector('.artifact-panel-body')
if (!body) return
// The List view is stable DOM (the auto-grouped card rows). Board
// and Timeline are re-rendered from state on every switch — cheap,
// since the entry count for a demo session is small and this keeps
// the projection honest as new events arrive.
const listGroup = body.querySelector('.artifact-group')
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
for (const s of stale) s.remove()
if (currentView === 'list') {
if (listGroup) listGroup.hidden = false
return
}
if (last && last.classList && last.classList.contains('artifact-card')) {
// Previous artifact is a lone card — promote it and the new one
// into a fresh group.
const group = document.createElement('div')
group.className = 'artifact-group'
stream.replaceChild(group, last)
group.appendChild(last)
group.appendChild(el)
return
if (listGroup) listGroup.hidden = true
const entries = collectLatestEntries()
const board = window.__dshArtifactsBoard
if (!board) return // module hasn't loaded yet; safe no-op
const view = currentView === 'board'
? board.renderBoard(entries, { openArtifact: (id) => window.dsh && window.dsh.openArtifact(id) })
: board.renderTimeline(entries, {
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
history,
})
body.appendChild(view)
}
// Snapshot the latest-version entry per artifactId — that's what
// Board tiles show. Timeline reads full history separately.
function collectLatestEntries() {
const out = []
for (const [id, arr] of history) {
if (!arr || arr.length === 0) continue
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
out.push({
artifactId: id,
version: latest.version,
kind: latest.kind,
path: latest.path,
seenAt: latest.seenAt,
blob: latest.blob,
})
}
stream.appendChild(el)
return out
}
function invokeOpen(entry, actionEl, restoreLabel) {
@@ -121,14 +267,15 @@
}
function renderCard(entry) {
// <details> is the L0 row shell. `open=false` keeps rows collapsed by
// default; clicking anywhere on the <summary> toggles the L1 body.
// <details> is the L0 row shell. `open=false` keeps rows collapsed
// by default; clicking anywhere on the <summary> toggles the L1
// body.
const el = document.createElement('details')
el.className = 'artifact-card'
el.dataset.artifactId = entry.artifactId
el.dataset.version = String(entry.version || 1)
// ---- L0 summary row ------------------------------------------------
// ---- L0 summary row -----------------------------------------------
const summary = document.createElement('summary')
summary.className = 'artifact-row'
@@ -145,9 +292,22 @@
kindEl.className = 'artifact-kind'
kindEl.textContent = entry.kind || 'file'
const verEl = document.createElement('span')
// Version chip: promoted from a static span to a <button> in V2 so
// clicking it opens the inline evolution strip. Preserves the same
// class name so the row layout / stylesheet locks still hold.
const verEl = document.createElement('button')
verEl.type = 'button'
verEl.className = 'artifact-version'
verEl.textContent = `v${entry.version || 1}`
verEl.title = 'View version history'
verEl.setAttribute('aria-label', `View version history for ${entry.artifactId}`)
verEl.addEventListener('click', (e) => {
// Prevent both the <details> toggle and the row default so the
// chip acts as its own trigger.
e.preventDefault()
e.stopPropagation()
toggleEvolution(el, entry)
})
const dotEl = document.createElement('span')
dotEl.className = 'artifact-live-dot'
@@ -173,7 +333,7 @@
summary.append(iconEl, nameEl, kindEl, verEl, dotEl, openLink)
// ---- L1 inline body (lazy content, structure is there for a11y) ----
// ---- L1 inline body (lazy content, structure is there for a11y) ---
const body = document.createElement('div')
body.className = 'artifact-body-l1'
const pathRow = document.createElement('div')
@@ -207,6 +367,43 @@
return el
}
// The evolution strip is a sibling under the card root, appended below
// the L1 body. Toggle: create on first click, remove on second. Sits
// outside the <details> body deliberately so the version chip acts
// independently of the row's open/close state — a user can inspect
// the version chain without expanding the path/actions body.
//
// Layout note: the strip is a child of the <details> element in the
// DOM, and a closed <details> hides all non-<summary> children per
// the HTML spec (no `display:` override wins against that). So the
// toggle-on branch also opens the details so the strip actually
// renders. The path + Open-in-browser row happen to appear too — an
// acceptable side effect since the user just declared interest in
// this artifact by clicking its version chip.
function toggleEvolution(cardEl, entry) {
const existing = cardEl.querySelector(':scope > .artifact-evolution')
if (existing) {
existing.remove()
cardEl.classList.remove('has-evolution')
return
}
const board = window.__dshArtifactsBoard
if (!board) return
const strip = board.renderEvolution(entry, history.get(entry.artifactId) || [])
cardEl.append(strip)
cardEl.classList.add('has-evolution')
cardEl.open = true
}
function refreshEvolutionIfOpen(cardEl, entry) {
const existing = cardEl.querySelector(':scope > .artifact-evolution')
if (!existing) return
const board = window.__dshArtifactsBoard
if (!board) return
const fresh = board.renderEvolution(entry, history.get(entry.artifactId) || [])
existing.replaceWith(fresh)
}
function updateCard(el, entry) {
el.dataset.version = String(entry.version || 1)
const ver = el.querySelector('.artifact-version')
@@ -218,8 +415,8 @@
function flash(el) {
el.classList.add('artifact-flash')
// Reflow trick so re-adding the class re-triggers the animation for a
// rapid second update.
// Reflow trick so re-adding the class re-triggers the animation for
// a rapid second update.
void el.offsetWidth
setTimeout(() => el.classList.remove('artifact-flash'), 900)
}
@@ -245,8 +442,9 @@
})
}
// Wire up once the DOM + preload bridge are ready. The renderer script tag
// is loaded after this one, so we just register the listener eagerly.
// Wire up once the DOM + preload bridge are ready. The renderer script
// tag is loaded after this one, so we just register the listener
// eagerly.
if (window.dsh && typeof window.dsh.onArtifact === 'function') {
window.dsh.onArtifact(onArtifactEvent)
}
@@ -256,6 +454,14 @@
bindMockButton()
}
// Expose the small API for the smoke tests + potential renderer-side reuse.
window.__dshArtifacts = { onArtifactEvent, cards }
// Expose the small API for the smoke tests + potential renderer-side
// reuse. `history` and `switchView` join the surface so fixture
// drivers can inspect state and QA can screenshot each view directly.
window.__dshArtifacts = {
onArtifactEvent,
cards,
history,
switchView,
getView: () => currentView,
}
})()