// 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
}
})()