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\nLanding\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 \n\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 + + + + +
+
+
+ Fixture: 3 multi-version + 4 single-version artifacts across 5 kinds + + + + + + +
+
+
+
+ + + + + + + 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: '

x

' }, + ] + const el = board.renderBoard(entries, { openArtifact: () => {} }) + assert.equal(el.className, 'artifact-board') + const groups = el.querySelectorAll('.artifact-board-group') + assert.equal(groups.length, 2, 'md + html only — svg/json/code/other are empty and must be dropped') + const mdGroup = groups.find ? groups.find((g) => g.dataset.kind === 'md') : groups[0] + const mdTiles = mdGroup.querySelectorAll('.artifact-tile') + assert.equal(mdTiles.length, 2, 'both md entries live under the md group') + // Kind badge on the tile mirrors the bucket so CSS can theme per-kind. + for (const tile of mdTiles) assert.equal(tile.dataset.kind, 'md') +}) + +test('artifacts-board.renderTimeline: newest-first ordering across all versions', () => { + makeDom() + const entries = [ + { artifactId: 'a.md', kind: 'md', version: 2, seenAt: 100 }, + ] + const history = new Map([ + ['a.md', [ + { artifactId: 'a.md', version: 1, kind: 'md', seenAt: 50 }, + { artifactId: 'a.md', version: 2, kind: 'md', seenAt: 100 }, + ]], + ]) + const el = board.renderTimeline(entries, { history, openArtifact: () => {} }) + const rows = el.querySelectorAll('.artifact-timeline-row') + assert.equal(rows.length, 2, 'both history versions must appear as rows') + assert.equal(rows[0].dataset.version, '2', 'newest version appears first') + assert.equal(rows[1].dataset.version, '1') +}) + +test('artifacts-board.renderEvolution: N versions → N-1 diff panes + honest fallback', () => { + makeDom() + const entry = { artifactId: 'a.md', kind: 'md', version: 3 } + // Chain with two hops. First hop has both blobs (real diff); second + // hop has one missing (fallback note path). + const history = [ + { version: 1, seenAt: 100, kind: 'md', blob: 'a\nb' }, + { version: 2, seenAt: 200, kind: 'md', blob: 'a\nB' }, + { version: 3, seenAt: 300, kind: 'md' /* no blob */ }, + ] + const el = board.renderEvolution(entry, history) + const steps = el.querySelectorAll('.artifact-evolution-step') + assert.equal(steps.length, 3, 'one step per version') + const diffs = el.querySelectorAll('.artifact-evolution-diff') + assert.equal(diffs.length, 2, 'N-1 diff panes for a chain of N versions') + // The first diff pane has real content; the second (v2 has blob, v3 + // does not) must fall back to the not-preserved note. + const notes = el.querySelectorAll('.artifact-evolution-diff-note') + assert.equal(notes.length, 1, 'exactly one hop falls back to the honest note') +}) + +test('artifacts-board.renderEvolution: chain summary reads v1 → v2 → v3', () => { + makeDom() + const entry = { artifactId: 'a.md', kind: 'md', version: 3 } + const history = [ + { version: 1, seenAt: 1 }, + { version: 2, seenAt: 2 }, + { version: 3, seenAt: 3 }, + ] + const el = board.renderEvolution(entry, history) + const sum = el.querySelector('.artifact-evolution-summary') + assert.ok(sum, 'summary line must render') + assert.equal(sum.textContent, 'v1 → v2 → v3', + 'chain summary must list every version in order') +}) + +// -- (b) artifacts.js source-string locks --------------------------------- + +test('artifacts.js: builds an .artifact-panel with a role="tablist" tab bar', () => { + assert.match( + artifactsSrc, + /panel\.className\s*=\s*['"]artifact-panel['"]/, + 'panel container must exist so the tab bar has a scope', + ) + assert.match( + artifactsSrc, + /tabBar\.setAttribute\(['"]role['"],\s*['"]tablist['"]\)/, + 'tab-bar container must carry role="tablist" for a11y', + ) + assert.match( + artifactsSrc, + /['"]list['"],\s*['"]board['"],\s*['"]timeline['"]/, + 'exactly three tabs must exist in the exact order List / Board / Timeline', + ) +}) + +test('artifacts.js: version chip is a