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 changed files with 1704 additions and 39 deletions

View File

@@ -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:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M11 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V8zM11 3v4a1 1 0 0 0 1 1h4M7 11h6M7 13h4"/>'
+ '</svg>',
html:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M11 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V8zM11 3v4a1 1 0 0 0 1 1h4"/>'
+ '</svg>',
svg:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<rect x="3" y="4" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>'
+ '<circle cx="7" cy="8" r="1.4" fill="none" stroke="currentColor" stroke-width="1.4"/>'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M4 14l4-4 3 3 3-3 3 3"/>'
+ '</svg>',
json:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M8 3H5.5A1.5 1.5 0 0 0 4 4.5v11A1.5 1.5 0 0 0 5.5 17H8M12 3h2.5A1.5 1.5 0 0 1 16 4.5v11a1.5 1.5 0 0 1-1.5 1.5H12"/>'
+ '</svg>',
code:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M7 6l-4 4 4 4M13 6l4 4-4 4M11 4l-2 12"/>'
+ '</svg>',
other:
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M14.5 8.5 8 15a3 3 0 0 1-4.2-4.2l7-7a2 2 0 0 1 2.8 2.8L7.5 12.5a1 1 0 0 1-1.4-1.4L12 5.5"/>'
+ '</svg>',
}
// 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 <section class="artifact-board-group"> 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 `<details>` 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
}
})()

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

View File

@@ -1324,6 +1324,10 @@
from any tab. -->
<script src="./fork-compare.js"></script>
<script src="./tool-cards.js"></script>
<!-- artifacts-board.js defines window.__dshArtifactsBoard (Board/Timeline/
Evolution renderers) and MUST load before artifacts.js so the L0 row
version-chip click handler + view switcher find the module ready. -->
<script src="./artifacts-board.js"></script>
<script src="./artifacts.js"></script>
<script src="./plugin-runtime-fold.js"></script>
<script src="./mcp-tool-name.js"></script>

View File

@@ -12101,3 +12101,265 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
outline: 2px solid rgba(122, 90, 248, 0.8);
outline-offset: -2px;
}
/* ==========================================================================
* Artifact panel — V2 (lane-artifact-v2, 2026-07-19)
*
* The panel wraps the artifact-group with a top tab bar (List/Board/
* Timeline). Keeps the L0 compact-row shape intact for List; Board and
* Timeline are computed projections that render inside the same body.
* The version-evolution strip is a sibling to `.artifact-body-l1`
* inside each card, toggled by clicking the `.artifact-version` chip.
*
* Design brief: on light/dark themes, keep the panel visually quiet —
* the tab bar sits on the same elevation as the card body so the panel
* reads as one surface, not a chrome-heavy widget. Board tiles reuse
* the shared card family variables so they don't stand out from the
* rest of the chat.
* ========================================================================== */
.artifact-panel {
align-self: flex-start; max-width: 780px; width: 100%;
display: flex; flex-direction: column;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px;
overflow: hidden;
}
.artifact-panel-tabs {
display: flex; gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
background: var(--bg-elev-2);
}
.artifact-panel-tab {
padding: 3px 10px;
border-radius: 4px;
background: transparent; border: 1px solid transparent;
color: var(--muted); font-size: 11px; line-height: 1.4;
cursor: pointer; letter-spacing: 0.02em;
}
.artifact-panel-tab:hover { background: var(--bg-elev); color: var(--text); }
.artifact-panel-tab[aria-selected="true"] {
background: var(--bg-elev); color: var(--text);
border-color: var(--border);
}
.artifact-panel-body {
display: flex; flex-direction: column;
}
/* When the panel hosts the group, the inner group loses its own outer
* shell — the panel already owns the border+radius. Grouped card rows
* still fuse via the existing `.artifact-group > .artifact-card` rules. */
.artifact-panel .artifact-group {
max-width: none; width: 100%;
border-radius: 0;
}
.artifact-panel .artifact-group > .artifact-card {
border-radius: 0;
border-left: none; border-right: none;
}
.artifact-panel .artifact-group > .artifact-card:first-child {
border-top: none;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.artifact-panel .artifact-group > .artifact-card:last-child {
border-bottom: none;
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
}
/* Version chip: promoted to a <button> in V2 so it can toggle the
* evolution strip. Keep the muted-accent look so it still reads as a
* chip, not a full button — a subtle underline on hover is the only
* hint that it's interactive. */
button.artifact-version {
background: transparent; border: none; padding: 0;
cursor: pointer;
font: inherit;
}
button.artifact-version:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.artifact-card.has-evolution > .artifact-row .artifact-version {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Evolution strip: appended below `.artifact-body-l1` inside the card
* root. Visible only when the version chip is toggled on. */
.artifact-evolution {
border-top: 1px solid var(--border);
padding: 8px 12px 12px 34px; /* match .artifact-body-l1 left inset */
background: var(--bg-elev-2);
}
.artifact-evolution-head {
display: flex; align-items: baseline; gap: 8px;
margin-bottom: 6px;
}
.artifact-evolution-label {
text-transform: uppercase; letter-spacing: 0.05em;
font-size: 10px; color: var(--text-tertiary);
}
.artifact-evolution-summary {
font-family: var(--mono); font-size: 11px; color: var(--muted);
}
.artifact-evolution-chain {
list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 6px;
}
.artifact-evolution-step {
border-left: 2px solid var(--border);
padding: 4px 0 4px 10px;
}
.artifact-evolution-step-head {
display: flex; align-items: baseline; gap: 8px;
}
.artifact-evolution-step-ver {
font-family: var(--mono); font-size: 11px; color: var(--accent);
}
.artifact-evolution-step-time { font-size: 10px; }
.artifact-evolution-seed {
font-size: 11px; margin-top: 2px;
}
.artifact-evolution-diff {
margin-top: 4px;
}
.artifact-evolution-diff-summary {
font-size: 11px; color: var(--muted); cursor: pointer;
padding: 2px 6px; border-radius: 3px;
display: inline-block;
}
.artifact-evolution-diff-summary:hover { background: var(--bg-elev); color: var(--text); }
.artifact-evolution-diff-summary::-webkit-details-marker { display: none; }
.artifact-evolution-diff-summary::marker { content: ''; }
.artifact-evolution-diff[open] .artifact-evolution-diff-summary {
background: var(--bg-elev); color: var(--text);
}
.artifact-evolution-diff-note {
padding: 6px 8px; font-size: 11px;
border: 1px dashed var(--border); border-radius: 4px;
margin-top: 4px;
}
.artifact-evolution-diff-body {
margin-top: 4px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--bg-elev);
font-family: var(--mono); font-size: 11px;
overflow-x: auto; max-height: 260px; overflow-y: auto;
}
.artifact-evolution-diff-line {
display: flex; align-items: baseline; gap: 4px;
padding: 1px 6px;
white-space: pre;
}
.artifact-evolution-diff-line.kind-add { background: color-mix(in oklab, var(--ok, #22a06b) 12%, transparent); }
.artifact-evolution-diff-line.kind-del { background: color-mix(in oklab, var(--danger, #e5484d) 12%, transparent); }
.artifact-evolution-diff-gutter {
color: var(--text-tertiary); width: 10px; flex: 0 0 auto;
font-family: var(--mono);
}
.artifact-evolution-diff-text {
color: var(--text); word-break: break-all;
}
/* Board view: group tiles by kind bucket. */
.artifact-board {
display: flex; flex-direction: column; gap: 14px;
padding: 12px;
}
.artifact-board-group {
display: flex; flex-direction: column; gap: 8px;
}
.artifact-board-group-head {
display: flex; align-items: center; gap: 8px;
color: var(--muted);
}
.artifact-board-group-icon { display: inline-flex; color: var(--muted); }
.artifact-board-group-title {
font-size: 12px; letter-spacing: 0.02em;
text-transform: uppercase; color: var(--text-tertiary);
}
.artifact-board-group-count {
font-family: var(--mono); font-size: 11px; color: var(--muted);
background: var(--bg-elev-2); border-radius: 3px;
padding: 0 6px;
}
.artifact-board-grid {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.artifact-tile {
display: flex; flex-direction: column;
background: var(--bg-elev-2); border: 1px solid var(--border);
border-radius: 6px; padding: 0; overflow: hidden;
cursor: pointer; text-align: left;
color: var(--text); font: inherit;
transition: border-color 0.2s, transform 0.15s;
}
.artifact-tile:hover {
border-color: var(--accent); transform: translateY(-1px);
background: var(--bg-elev);
}
.artifact-thumb {
min-height: 84px; max-height: 84px;
padding: 8px 10px;
display: flex; align-items: flex-start; justify-content: flex-start;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.artifact-thumb-text {
margin: 0;
font-family: var(--mono); font-size: 10.5px; line-height: 1.4;
color: var(--muted);
white-space: pre-wrap; word-break: break-word;
max-height: 68px; overflow: hidden;
}
.artifact-thumb-icon {
color: var(--muted); display: inline-flex;
}
.artifact-tile-meta {
display: flex; align-items: baseline; justify-content: space-between;
gap: 6px; padding: 6px 10px;
}
.artifact-tile-name {
font-family: var(--mono); font-size: 11px; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1 1 auto; min-width: 0;
}
.artifact-tile-version {
color: var(--accent); font-family: var(--mono); font-size: 10.5px;
flex: 0 0 auto;
}
/* Timeline view: chronological rows. */
.artifact-timeline {
display: flex; flex-direction: column;
padding: 4px 0;
}
.artifact-timeline-row {
display: flex; align-items: center; gap: 8px;
padding: 4px 12px; min-height: 26px;
font-size: 11px; color: var(--text);
border-bottom: 1px dashed var(--border);
}
.artifact-timeline-row:last-child { border-bottom: none; }
.artifact-timeline-time {
font-family: var(--mono); color: var(--text-tertiary);
width: 72px; flex: 0 0 auto;
}
.artifact-timeline-icon {
display: inline-flex; color: var(--muted); flex: 0 0 auto;
}
.artifact-timeline-name {
font-family: var(--mono); font-size: 11px;
flex: 1 1 auto; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.artifact-timeline-version {
color: var(--accent); font-family: var(--mono); font-size: 10.5px;
flex: 0 0 auto;
}
.artifact-timeline-open {
font-size: 10.5px; color: var(--muted); text-decoration: none;
padding: 2px 4px; border-radius: 3px; flex: 0 0 auto;
}
.artifact-timeline-open:hover { color: var(--accent); background: var(--accent-soft); }