diff --git a/examples/desktop/docs/artifact-board-fixture.json b/examples/desktop/docs/artifact-board-fixture.json
new file mode 100644
index 0000000000..58d96ead20
--- /dev/null
+++ b/examples/desktop/docs/artifact-board-fixture.json
@@ -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": "\n\n
Landing\n\n Hello
\n\n\n"
+ },
+ {
+ "artifactId": "landing.html",
+ "kind": "html",
+ "version": 2,
+ "seenAt": 1747000090000,
+ "path": "/w/.artifacts/landing.html",
+ "blob": "\n\n\n Landing\n \n\n\n Hello
\n Welcome to our product.
\n\n\n"
+ },
+ {
+ "artifactId": "hero.svg",
+ "kind": "svg",
+ "version": 1,
+ "seenAt": 1747000045000,
+ "path": "/w/.artifacts/hero.svg",
+ "blob": "\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"
+ }
+ ]
+}
diff --git a/examples/desktop/docs/qa-artifact-evolution/01-list.png b/examples/desktop/docs/qa-artifact-evolution/01-list.png
new file mode 100644
index 0000000000..50e3eb72a9
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-evolution/01-list.png differ
diff --git a/examples/desktop/docs/qa-artifact-evolution/02-board.png b/examples/desktop/docs/qa-artifact-evolution/02-board.png
new file mode 100644
index 0000000000..94427ac649
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-evolution/02-board.png differ
diff --git a/examples/desktop/docs/qa-artifact-evolution/03-evolution.png b/examples/desktop/docs/qa-artifact-evolution/03-evolution.png
new file mode 100644
index 0000000000..bc8ebcda9a
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-evolution/03-evolution.png differ
diff --git a/examples/desktop/docs/qa-artifact-evolution/fixture.html b/examples/desktop/docs/qa-artifact-evolution/fixture.html
new file mode 100644
index 0000000000..ae86970028
--- /dev/null
+++ b/examples/desktop/docs/qa-artifact-evolution/fixture.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+ lane-artifact-v2 fixture — Board + evolution
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/desktop/scripts/qa-cdp-shoot-artifact-v2.mjs b/examples/desktop/scripts/qa-cdp-shoot-artifact-v2.mjs
new file mode 100644
index 0000000000..fd87fd9ab7
--- /dev/null
+++ b/examples/desktop/scripts/qa-cdp-shoot-artifact-v2.mjs
@@ -0,0 +1,179 @@
+// QA shoot for lane-artifact-v2 (Artifacts evolution chain + Board view).
+//
+// Cheap variant vs. the full electron-shoot pattern (qa-cdp-shoot-*.mjs):
+// this loads docs/qa-artifact-evolution/fixture.html directly in a bare
+// Electron BrowserWindow — no user-data-dir, no seed overlay, no
+// $DSH_DESKTOP_HOME plumbing, because the fixture doesn't touch profiles
+// / plugins / main.js. It just needs a Chromium to render style.css +
+// artifacts.js + artifacts-board.js against the fixture JSON.
+//
+// Shoots three screenshots into docs/qa-artifact-evolution/:
+// 01-list.png — default List view (compact rows + auto-group)
+// 02-board.png — Board view grouped by kind
+// 03-evolution.png — session.md's evolution strip expanded, showing
+// the v1→v2 diff pane opened
+
+import { spawn } from 'node:child_process'
+import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
+import { resolve, join } from 'node:path'
+import { setTimeout as sleep } from 'node:timers/promises'
+import { tmpdir } from 'node:os'
+
+const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
+const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
+const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
+const CDP_PORT = Number(process.env.DSH_ARTIFACT_V2_PORT || 9276)
+
+const OUTDIR = join(WORKTREE, 'docs/qa-artifact-evolution')
+if (!existsSync(OUTDIR)) mkdirSync(OUTDIR, { recursive: true })
+
+// Tiny electron main.js: loads a single file via `file://`. Written to
+// /tmp/dsh-artifact-v2-/main.js so we don't pollute the worktree.
+const APPDIR = join(tmpdir(), `dsh-artifact-v2-${process.pid}`)
+mkdirSync(APPDIR, { recursive: true })
+const fixturePath = join(WORKTREE, 'docs/qa-artifact-evolution/fixture.html')
+writeFileSync(join(APPDIR, 'package.json'), JSON.stringify({
+ name: 'dsh-artifact-v2-shoot', main: 'main.js',
+}))
+writeFileSync(join(APPDIR, 'main.js'), `
+const { app, BrowserWindow } = require('electron')
+app.commandLine.appendSwitch('remote-debugging-port', '${CDP_PORT}')
+app.whenReady().then(() => {
+ const win = new BrowserWindow({
+ width: 1200, height: 900,
+ webPreferences: { nodeIntegration: false, contextIsolation: true },
+ })
+ win.loadFile(${JSON.stringify(fixturePath)})
+})
+app.on('window-all-closed', () => app.quit())
+`)
+
+async function bootElectron() {
+ const child = spawn(ELECTRON, ['.'], {
+ cwd: APPDIR,
+ env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: '1' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ const logs = []
+ child.stdout.on('data', (d) => logs.push(String(d)))
+ child.stderr.on('data', (d) => logs.push(String(d)))
+ for (let i = 0; i < 40; i++) {
+ await sleep(500)
+ try {
+ const r = await fetch(`http://localhost:${CDP_PORT}/json/list`)
+ if (r.ok) return { child, logs }
+ } catch {}
+ }
+ child.kill('SIGKILL')
+ console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
+ process.exit(3)
+}
+
+async function newCdp() {
+ const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json()
+ const target = targets.find((t) => t.type === 'page')
+ if (!target) throw new Error('no page target on port ' + CDP_PORT)
+ const ws = new WebSocket(target.webSocketDebuggerUrl)
+ await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = (e) => err(e) })
+ let id = 1
+ const pending = new Map()
+ ws.onmessage = (ev) => {
+ const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
+ if (msg.id != null && pending.has(msg.id)) {
+ const [ok, err] = pending.get(msg.id)
+ pending.delete(msg.id)
+ if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
+ }
+ }
+ const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
+ const _id = id++
+ const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
+ pending.set(_id, [(v) => { clearTimeout(t); ok(v) }, (e) => { clearTimeout(t); err(e) }])
+ ws.send(JSON.stringify({ id: _id, method: m, params: p }))
+ })
+ const evj = async (expr) => {
+ const r = await call('Runtime.evaluate', {
+ expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
+ returnByValue: true, awaitPromise: true,
+ })
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
+ return r.result?.value
+ }
+ return { ws, call, evj }
+}
+
+async function main() {
+ const { child, logs } = await bootElectron()
+ const kill = () => { try { child.kill('SIGKILL') } catch {} }
+ process.on('exit', kill)
+ process.on('SIGINT', () => { kill(); process.exit(1) })
+
+ try {
+ await sleep(1500)
+ const cdp = await newCdp()
+ await cdp.call('Page.enable')
+ await cdp.call('Emulation.setDeviceMetricsOverride', {
+ width: 1200, height: 900, deviceScaleFactor: 2, mobile: false,
+ })
+
+ // Wait for fixture data to load — the fetch() inside fixture.html
+ // is async so give it a beat.
+ await sleep(1500)
+ const state = await cdp.evj(`(() => {
+ const api = window.__dshArtifacts
+ const board = window.__dshArtifactsBoard
+ return {
+ cards: api ? api.cards.size : -1,
+ historyKeys: api ? [...api.history.keys()] : [],
+ boardOk: !!board,
+ }
+ })()`)
+ console.error('fixture state:', JSON.stringify(state))
+ if (!state.cards || state.cards < 5) throw new Error('fixture did not populate: ' + JSON.stringify(state))
+
+ const shoot = async (name) => {
+ await sleep(300)
+ const r = await cdp.call('Page.captureScreenshot', { format: 'png' }, 30000)
+ writeFileSync(join(OUTDIR, name + '.png'), Buffer.from(r.data, 'base64'))
+ console.error('wrote', name + '.png')
+ }
+
+ // 01. List view — default state, auto-grouped rows.
+ await shoot('01-list')
+
+ // 02. Board view.
+ await cdp.evj(`window.__dshArtifacts.switchView('board')`)
+ await sleep(400)
+ await shoot('02-board')
+
+ // 03. Evolution strip — return to List, click session.md's version
+ // chip, and open the first diff pane so the diff is captured in the
+ // resting state.
+ await cdp.evj(`window.__dshArtifacts.switchView('list')`)
+ await sleep(300)
+ await cdp.evj(`(() => {
+ const card = document.querySelector('.artifact-card[data-artifact-id="session.md"]')
+ const chip = card && card.querySelector('.artifact-version')
+ if (chip) chip.click()
+ // Open the first diff pane so the v1→v2 line-diff is visible in the shot.
+ const firstDiff = document.querySelector('.artifact-evolution-diff')
+ if (firstDiff) firstDiff.open = true
+ return true
+ })()`)
+ await sleep(400)
+ // Scroll the strip into view so the shot's top has it.
+ await cdp.evj(`document.querySelector('.artifact-evolution').scrollIntoView({ block: 'start' })`)
+ await sleep(200)
+ await shoot('03-evolution')
+
+ console.error('DONE — shots at', OUTDIR)
+ } finally {
+ kill()
+ try { rmSync(APPDIR, { recursive: true, force: true }) } catch {}
+ }
+}
+
+main().catch((err) => {
+ console.error('shoot failed:', err)
+ process.exit(1)
+})
diff --git a/examples/desktop/src/renderer/artifacts-board.js b/examples/desktop/src/renderer/artifacts-board.js
new file mode 100644
index 0000000000..51c8083d98
--- /dev/null
+++ b/examples/desktop/src/renderer/artifacts-board.js
@@ -0,0 +1,426 @@
+// Artifact panel views: Board grid + Timeline list + Evolution chain.
+//
+// The compact L0 row (density-spec §2, artifacts.js) is the default List
+// view. This module adds two more projections of the same event stream:
+//
+// * Board — group by kind (md / html / svg / json / code / other),
+// each group a tile grid with thumbnails. Good when the
+// front-end task is producing several files and you want to
+// see "what's ready" at a glance rather than scroll rows.
+// * Timeline — chronological, one row per artifact-version tuple, so
+// you can watch "which file changed when" over the session.
+//
+// Plus the version-evolution chain: clicking a `.artifact-version` chip
+// on any List row expands an inline strip listing every version this
+// artifact has seen, with a per-hop diff pane. The chain is fed from
+// state.history[artifactId] which artifacts.js maintains as events arrive.
+//
+// Runtime constraint: the artifact server only serves the CURRENT file
+// contents (see src/main/artifact-server.js — no per-version blob store).
+// This module accepts an optional `blob` field on history entries so the
+// fixture can demo real diffs; when absent, the pre-latest hops render a
+// "content not preserved for older versions" placeholder — an honest
+// signal that the diff is fixture-tier until the runtime seam grows a
+// snapshot store (RFC follow-up).
+
+'use strict'
+
+;(function () {
+ const KINDS = ['md', 'html', 'svg', 'json', 'code', 'other']
+ const KIND_LABEL = {
+ md: 'Markdown', html: 'HTML', svg: 'SVG', json: 'JSON',
+ code: 'Code', other: 'Other',
+ }
+ // Mirrors artifacts.js's ICON_SVG shape — inline stroke icons keep the
+ // Board tiles consistent with the row icons on the List view.
+ const KIND_ICON = {
+ md:
+ '',
+ html:
+ '',
+ svg:
+ '',
+ json:
+ '',
+ code:
+ '',
+ other:
+ '',
+ }
+
+ // Normalize the free-form `kind` value each event carries into one of
+ // the six Board buckets — HTML from real artifacts sometimes arrives as
+ // 'html', 'htm', 'text/html'; keep the mapping generous.
+ function bucketOf(kind) {
+ if (!kind) return 'other'
+ const k = String(kind).toLowerCase()
+ if (k.includes('md') || k.includes('markdown')) return 'md'
+ if (k.includes('svg')) return 'svg'
+ if (k.includes('html') || k.includes('htm')) return 'html'
+ if (k.includes('json')) return 'json'
+ if (['js', 'ts', 'py', 'go', 'rs', 'sh', 'c', 'cpp', 'rb', 'java'].some((x) => k === x || k.includes(x))) return 'code'
+ return 'other'
+ }
+
+ function groupByKind(entries) {
+ const groups = new Map()
+ for (const k of KINDS) groups.set(k, [])
+ for (const e of entries) {
+ const b = bucketOf(e.kind)
+ groups.get(b).push(e)
+ }
+ // Drop empty buckets so the Board doesn't render empty group headers
+ // when a session has only produced markdown, etc.
+ for (const k of KINDS) {
+ if (groups.get(k).length === 0) groups.delete(k)
+ }
+ return groups
+ }
+
+ function firstLine(text) {
+ if (!text) return ''
+ const nl = text.indexOf('\n')
+ return (nl < 0 ? text : text.slice(0, nl)).trim()
+ }
+
+ // Build a small text preview for the tile: MD → first non-blank line;
+ // JSON → first two keys; code → first two non-blank lines; SVG/HTML
+ // fall back to the filename since a code preview isn't legible.
+ function thumbnailPreview(entry) {
+ const b = bucketOf(entry.kind)
+ const blob = entry.blob || ''
+ if (b === 'md') return firstLine(blob) || entry.artifactId
+ if (b === 'json') {
+ try {
+ const parsed = JSON.parse(blob)
+ const keys = Object.keys(parsed).slice(0, 3)
+ return keys.length ? '{ ' + keys.join(', ') + ' }' : entry.artifactId
+ } catch {
+ return firstLine(blob) || entry.artifactId
+ }
+ }
+ if (b === 'code') {
+ const lines = blob.split('\n').filter((l) => l.trim()).slice(0, 3)
+ return lines.join('\n') || entry.artifactId
+ }
+ if (b === 'html') return firstLine(blob.replace(/<[^>]+>/g, ' ')) || entry.artifactId
+ return ''
+ }
+
+ // Board renderer: one per kind
+ // bucket, containing a grid of `.artifact-tile` cards. Clicking a tile
+ // opens the artifact in the system browser (same seam as the L0
+ // `open ↗` link on the List view).
+ function renderBoard(entries, opts) {
+ const openArtifact = (opts && opts.openArtifact) || (() => {})
+ const el = document.createElement('div')
+ el.className = 'artifact-board'
+ const groups = groupByKind(entries)
+ for (const [kind, group] of groups) {
+ const section = document.createElement('section')
+ section.className = 'artifact-board-group'
+ section.dataset.kind = kind
+ const head = document.createElement('header')
+ head.className = 'artifact-board-group-head'
+ const icon = document.createElement('span')
+ icon.className = 'artifact-board-group-icon'
+ icon.innerHTML = KIND_ICON[kind] || KIND_ICON.other
+ const title = document.createElement('span')
+ title.className = 'artifact-board-group-title'
+ title.textContent = KIND_LABEL[kind] || kind
+ const count = document.createElement('span')
+ count.className = 'artifact-board-group-count'
+ count.textContent = String(group.length)
+ head.append(icon, title, count)
+ section.append(head)
+
+ const grid = document.createElement('div')
+ grid.className = 'artifact-board-grid'
+ for (const entry of group) {
+ grid.appendChild(renderTile(entry, openArtifact))
+ }
+ section.append(grid)
+ el.append(section)
+ }
+ return el
+ }
+
+ function renderTile(entry, openArtifact) {
+ const tile = document.createElement('button')
+ tile.type = 'button'
+ tile.className = 'artifact-tile'
+ tile.dataset.artifactId = entry.artifactId
+ tile.dataset.kind = bucketOf(entry.kind)
+ tile.setAttribute('aria-label', `Open ${entry.artifactId} v${entry.version || 1} in browser`)
+ tile.addEventListener('click', (e) => {
+ e.preventDefault()
+ Promise.resolve().then(() => openArtifact(entry.artifactId))
+ })
+ const thumb = document.createElement('div')
+ thumb.className = 'artifact-thumb'
+ thumb.dataset.kind = bucketOf(entry.kind)
+ const preview = thumbnailPreview(entry)
+ if (preview) {
+ const pre = document.createElement('pre')
+ pre.className = 'artifact-thumb-text'
+ pre.textContent = preview
+ thumb.append(pre)
+ } else {
+ const icon = document.createElement('span')
+ icon.className = 'artifact-thumb-icon'
+ icon.innerHTML = KIND_ICON[bucketOf(entry.kind)] || KIND_ICON.other
+ thumb.append(icon)
+ }
+ const meta = document.createElement('div')
+ meta.className = 'artifact-tile-meta'
+ const name = document.createElement('span')
+ name.className = 'artifact-tile-name'
+ name.textContent = entry.artifactId
+ name.title = entry.path || entry.artifactId
+ const ver = document.createElement('span')
+ ver.className = 'artifact-tile-version'
+ ver.textContent = `v${entry.version || 1}`
+ meta.append(name, ver)
+ tile.append(thumb, meta)
+ return tile
+ }
+
+ // Timeline renderer: chronological (newest first), one row per
+ // artifact-version tuple pulled from `history`. Each row shows kind,
+ // name, version, and a relative timestamp — good for auditing
+ // "when did which file mutate" during a long session.
+ function renderTimeline(entries, opts) {
+ const openArtifact = (opts && opts.openArtifact) || (() => {})
+ const history = (opts && opts.history) || new Map()
+ const el = document.createElement('div')
+ el.className = 'artifact-timeline'
+
+ // Flatten history into a single sorted stream.
+ const events = []
+ for (const entry of entries) {
+ const hist = history.get(entry.artifactId) || [{
+ artifactId: entry.artifactId,
+ version: entry.version || 1,
+ seenAt: entry.seenAt || Date.now(),
+ kind: entry.kind,
+ path: entry.path,
+ }]
+ for (const h of hist) events.push({ ...h, kind: h.kind || entry.kind, path: h.path || entry.path })
+ }
+ events.sort((a, b) => (b.seenAt || 0) - (a.seenAt || 0))
+ for (const ev of events) {
+ const row = document.createElement('div')
+ row.className = 'artifact-timeline-row'
+ row.dataset.artifactId = ev.artifactId
+ row.dataset.version = String(ev.version)
+ const stamp = document.createElement('span')
+ stamp.className = 'artifact-timeline-time'
+ stamp.textContent = formatRelative(ev.seenAt)
+ stamp.title = new Date(ev.seenAt).toISOString()
+ const icon = document.createElement('span')
+ icon.className = 'artifact-timeline-icon'
+ icon.innerHTML = KIND_ICON[bucketOf(ev.kind)] || KIND_ICON.other
+ const name = document.createElement('span')
+ name.className = 'artifact-timeline-name'
+ name.textContent = ev.artifactId
+ const ver = document.createElement('span')
+ ver.className = 'artifact-timeline-version'
+ ver.textContent = `v${ev.version}`
+ const openLink = document.createElement('a')
+ openLink.className = 'artifact-timeline-open'
+ openLink.href = '#'
+ openLink.textContent = 'open ↗'
+ openLink.addEventListener('click', (e) => {
+ e.preventDefault()
+ Promise.resolve().then(() => openArtifact(ev.artifactId))
+ })
+ row.append(stamp, icon, name, ver, openLink)
+ el.append(row)
+ }
+ return el
+ }
+
+ function formatRelative(ts) {
+ if (!ts) return ''
+ const delta = Date.now() - ts
+ if (delta < 60_000) return 'just now'
+ if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`
+ if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`
+ return `${Math.round(delta / 86_400_000)}d ago`
+ }
+
+ // Evolution chain: N version records → N-1 inter-version diff panes.
+ // Each hop is a `` collapsed by default (opens on click) so a
+ // long history doesn't dominate vertical space. The pane body renders
+ // a naïve line-diff — pre-latest hops fall back to a "content not
+ // preserved" placeholder when no `blob` field is available (the real
+ // runtime doesn't retain older blobs; the fixture provides them).
+ function renderEvolution(entry, history) {
+ const chain = (history || []).slice().sort((a, b) => (a.version || 0) - (b.version || 0))
+ const el = document.createElement('div')
+ el.className = 'artifact-evolution'
+ el.dataset.artifactId = entry.artifactId
+
+ const head = document.createElement('div')
+ head.className = 'artifact-evolution-head'
+ const label = document.createElement('span')
+ label.className = 'artifact-evolution-label'
+ label.textContent = 'evolution'
+ const summary = document.createElement('span')
+ summary.className = 'artifact-evolution-summary'
+ const versions = chain.map((c) => `v${c.version}`).join(' → ')
+ summary.textContent = versions || `v${entry.version || 1}`
+ head.append(label, summary)
+ el.append(head)
+
+ const chainEl = document.createElement('ol')
+ chainEl.className = 'artifact-evolution-chain'
+ for (let i = 0; i < chain.length; i++) {
+ const step = chain[i]
+ const stepEl = document.createElement('li')
+ stepEl.className = 'artifact-evolution-step'
+ stepEl.dataset.version = String(step.version)
+ const stepHead = document.createElement('div')
+ stepHead.className = 'artifact-evolution-step-head'
+ const ver = document.createElement('span')
+ ver.className = 'artifact-evolution-step-ver'
+ ver.textContent = `v${step.version}`
+ const stamp = document.createElement('span')
+ stamp.className = 'artifact-evolution-step-time muted'
+ stamp.textContent = formatRelative(step.seenAt)
+ stepHead.append(ver, stamp)
+ stepEl.append(stepHead)
+
+ if (i > 0) {
+ const prev = chain[i - 1]
+ const diffPane = renderDiffPane(prev, step)
+ stepEl.append(diffPane)
+ } else {
+ const seedNote = document.createElement('div')
+ seedNote.className = 'artifact-evolution-seed muted'
+ seedNote.textContent = 'initial version'
+ stepEl.append(seedNote)
+ }
+ chainEl.append(stepEl)
+ }
+ el.append(chainEl)
+ return el
+ }
+
+ function renderDiffPane(prev, next) {
+ const pane = document.createElement('details')
+ pane.className = 'artifact-evolution-diff'
+ pane.open = false
+ const sum = document.createElement('summary')
+ sum.className = 'artifact-evolution-diff-summary'
+ sum.textContent = `diff v${prev.version} → v${next.version}`
+ pane.append(sum)
+
+ // If either side is missing a blob, we can't compute a diff. This is
+ // the honest state for the real runtime (older blobs aren't cached).
+ if (!prev.blob || !next.blob) {
+ const note = document.createElement('div')
+ note.className = 'artifact-evolution-diff-note muted'
+ note.textContent = 'content for older versions is not preserved by the artifact server — diff unavailable'
+ pane.append(note)
+ return pane
+ }
+ const diffEl = document.createElement('div')
+ diffEl.className = 'artifact-evolution-diff-body'
+ const lines = diffLines(prev.blob, next.blob)
+ for (const ln of lines) {
+ const row = document.createElement('div')
+ row.className = `artifact-evolution-diff-line kind-${ln.kind}`
+ const gutter = document.createElement('span')
+ gutter.className = 'artifact-evolution-diff-gutter'
+ gutter.textContent = ln.kind === 'add' ? '+' : ln.kind === 'del' ? '-' : ' '
+ const body = document.createElement('span')
+ body.className = 'artifact-evolution-diff-text'
+ body.textContent = ln.text
+ row.append(gutter, body)
+ diffEl.append(row)
+ }
+ pane.append(diffEl)
+ return pane
+ }
+
+ // Line-level diff, LCS-style. Kept tiny on purpose — the artifact
+ // demo isn't a git-scale diff engine and blobs here are small
+ // (single-page HTML, short markdown). For big files we'd swap in
+ // tool-cards.js's diffLines(); this is deliberately dependency-free
+ // so the module unit tests don't have to boot the whole renderer.
+ function diffLines(a, b) {
+ const aLines = a.split('\n')
+ const bLines = b.split('\n')
+ const n = aLines.length
+ const m = bLines.length
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ if (aLines[i] === bLines[j]) dp[i][j] = dp[i + 1][j + 1] + 1
+ else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
+ }
+ }
+ const out = []
+ let i = 0
+ let j = 0
+ while (i < n && j < m) {
+ if (aLines[i] === bLines[j]) {
+ out.push({ kind: 'ctx', text: aLines[i] })
+ i++
+ j++
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
+ out.push({ kind: 'del', text: aLines[i] })
+ i++
+ } else {
+ out.push({ kind: 'add', text: bLines[j] })
+ j++
+ }
+ }
+ while (i < n) { out.push({ kind: 'del', text: aLines[i++] }) }
+ while (j < m) { out.push({ kind: 'add', text: bLines[j++] }) }
+ return out
+ }
+
+ const api = {
+ KINDS,
+ bucketOf,
+ groupByKind,
+ renderBoard,
+ renderTimeline,
+ renderEvolution,
+ thumbnailPreview,
+ diffLines,
+ formatRelative,
+ }
+
+ // Dual export: CommonJS for node:test (test/artifact-evolution-board.test.js
+ // asserts on the module surface without booting Electron), window for
+ // the renderer to consume via artifacts.js. Mirrors the pattern used by
+ // tool-cards.js and widgets.js.
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api
+ }
+ if (typeof window !== 'undefined') {
+ window.__dshArtifactsBoard = api
+ }
+})()
diff --git a/examples/desktop/src/renderer/artifacts.js b/examples/desktop/src/renderer/artifacts.js
index d43c35b64f..67cf82418a 100644
--- a/examples/desktop/src/renderer/artifacts.js
+++ b/examples/desktop/src/renderer/artifacts.js
@@ -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
-// 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 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:
'