// 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"). // // 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`). // 2. debug menu "mock: artifact" button (window.dsh.mockArtifact). // // De-dup: one card per artifactId per stream. If a re-declare fires the // existing card bumps its version + flashes. 'use strict' ;(function () { const streamEl = () => document.getElementById('stream') // 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. const ICON_SVG = { html: '', svg: '', md: '', } const ICON_FALLBACK = '' function scrollToBottom() { const s = streamEl() 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 } // Fuse consecutive artifact cards into an `.artifact-group` wrapper so // 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) { // 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 (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, }) } return out } function invokeOpen(entry, actionEl, restoreLabel) { if (!actionEl) return actionEl.setAttribute('aria-disabled', 'true') actionEl.classList.add('is-busy') const done = (label) => { actionEl.textContent = label setTimeout(() => { actionEl.textContent = restoreLabel actionEl.removeAttribute('aria-disabled') actionEl.classList.remove('is-busy') }, 1500) } Promise.resolve() .then(() => window.dsh.openArtifact(entry.artifactId)) .then((r) => { if (r && r.ok) done('opened ↗') else done('failed') }) .catch((err) => { console.error('openArtifact failed', err) done('error') }) } function renderCard(entry) { //
is the L0 row shell. `open=false` keeps rows collapsed // by default; clicking anywhere on the 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 ----------------------------------------------- const summary = document.createElement('summary') summary.className = 'artifact-row' const iconEl = document.createElement('span') iconEl.className = 'artifact-icon' iconEl.innerHTML = ICON_SVG[entry.kind] || ICON_FALLBACK const nameEl = document.createElement('span') nameEl.className = 'artifact-name' nameEl.textContent = entry.artifactId nameEl.title = entry.path || entry.artifactId const kindEl = document.createElement('span') kindEl.className = 'artifact-kind' kindEl.textContent = entry.kind || 'file' // Version chip: promoted from a static span to a