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:
11 files changed
+1704
-39
No files matched your search
@@ -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,
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user