// 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') // Session-scoped state. Both `cards` (artifactId -> DOM element) and // `history` (artifactId -> [{ version, seenAt, kind, path, blob? }, …]) // used to be module-level singletons, which meant switching from // session A (three versions of foo.md) to session B (a fresh foo.md) // and back showed the B versions inside A's Board/Timeline. Bucketing // by sessionId isolates each session's stream while keeping the // per-session state addressable when the user tabs back to A. // // Sentinel `__default__` receives artifact events that arrive before // any session id is known (typical for early boot / debug fixture // buttons). Once a real sessionId flows in, that bucket is orphaned; // it never leaks across real sessions. const DEFAULT_SESSION = '__default__' const bySession = new Map() // sessionId -> { cards, history, panelEl, currentView } let activeSessionId = DEFAULT_SESSION function ensureBucket(sid) { let b = bySession.get(sid) if (!b) { b = { cards: new Map(), history: new Map(), panelEl: null, currentView: 'list', // Per-card inline-preview open state, keyed by artifactId. Cards // persist in the transcript DOM so their expanded/collapsed state // survives naturally, but tracking it here lets a rebuilt card // (and the unit tests) restore the last state deterministically. previewOpen: new Set(), } bySession.set(sid, b) } return b } function bucket() { return ensureBucket(activeSessionId) } // Accessors kept so the rest of the file reads naturally. const cards = { get: (id) => bucket().cards.get(id), set: (id, v) => bucket().cards.set(id, v) } const history = { get: (id) => bucket().history.get(id), set: (id, v) => bucket().history.set(id, v), entries: () => bucket().history.entries(), clear: () => bucket().history.clear(), } function setActiveSession(sid) { const next = typeof sid === 'string' && sid ? sid : DEFAULT_SESSION if (next === activeSessionId) return activeSessionId = next // Ensure the incoming session's bucket exists so subsequent event/ // switchView calls don't race on an unset entry. ensureBucket(next) } // 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 (bucket().currentView !== 'list') refreshView() return existing } const el = renderCard(entry) cards.set(entry.artifactId, el) const s = streamEl() if (s) appendGrouped(s, el) if (bucket().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. const b = bucket() if (!b.panelEl || !b.panelEl.isConnected) { b.panelEl = buildPanel() stream.appendChild(b.panelEl) } // Remove the cold-clone empty-state hint (if present) once we have // a real artifact to show. Introduced by lane-usability-fix so // seedBoardFixture's placeholder gets swept when a real artifact // event arrives. const emptyHint = b.panelEl.querySelector('[data-role="empty-state"]') if (emptyHint && emptyHint.parentNode) emptyHint.parentNode.removeChild(emptyHint) const groupHost = b.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) { const b = bucket() if (v === b.currentView) return b.currentView = v if (!b.panelEl) return b.panelEl.dataset.view = v for (const tab of b.panelEl.querySelectorAll('.artifact-panel-tab')) { tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false') } refreshView() } function refreshView() { const b = bucket() if (!b.panelEl) return const body = b.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 (b.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 // Adapter so artifacts-board's Timeline sees a Map-shaped `history` // (get / entries) even though ours is session-scoped through a // bucket. Snapshot the current session's Map so the renderer can't // observe writes from a subsequent session switch mid-render. const historyMap = b.history const view = b.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: historyMap, }) 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.entries()) { 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 } // Classify an artifact for inline preview. `.md` gets the mini markdown // renderer; `.html` gets a sandboxed iframe pointed at the artifact // server. Everything else has no inline preview (open-in-browser only). // Kind is taken from the event's `kind` when present, else sniffed off // the path extension so fixture events that only carry a path still work. function previewKind(entry) { const kind = String(entry.kind || '').toLowerCase() if (kind === 'md' || kind === 'markdown') return 'md' if (kind === 'html') return 'html' const p = String(entry.path || entry.artifactId || '').toLowerCase() if (/\.(md|markdown)$/.test(p)) return 'md' if (/\.html?$/.test(p)) return 'html' return null } // Latest captured blob for an artifact, if any. The real ArtifactServer // does not ship content on the wire, but fixtures (and the debug seed) // do; recordHistory keeps it. Used as the md preview source so the // fixture path renders real content without a network round-trip. function latestBlob(entry) { if (typeof entry.blob === 'string') return entry.blob const arr = history.get(entry.artifactId) if (!arr || !arr.length) return null const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b)) return typeof latest.blob === 'string' ? latest.blob : null } // Build the artifact server URL for an .html artifact. Prefer the URL the // event already carries (real ArtifactServer stamps `url`); otherwise ask // the preload bridge for the base and compose it the same way the server // does (encodeURIComponent, but keep `/` path separators). Returns null // when no server is up so callers fall back to open-in-browser. async function resolveHtmlUrl(entry) { if (typeof entry.url === 'string' && entry.url) return entry.url if (!(window.dsh && typeof window.dsh.getArtifactBase === 'function')) return null try { const base = await window.dsh.getArtifactBase() if (!base || !base.url) return null const id = encodeURIComponent(entry.artifactId).replace(/%2F/gi, '/') return base.url.replace(/\/$/, '') + '/a/' + id + '/' } catch (err) { console.error('getArtifactBase failed', err) return null } } // Inline preview strip: a
-free expandable region (we can't nest // a
inside the card's
and get independent toggle // state, so this is a button + region pair). Collapsed by default; // content builds lazily on first expand. Open state is mirrored into the // session bucket's previewOpen set so it's restorable. function buildPreview(entry) { const kind = previewKind(entry) if (kind !== 'md' && kind !== 'html') return null const wrap = document.createElement('div') wrap.className = 'artifact-preview' wrap.dataset.previewKind = kind const toggle = document.createElement('button') toggle.type = 'button' toggle.className = 'artifact-preview-toggle' toggle.setAttribute('aria-expanded', 'false') const caret = document.createElement('span') caret.className = 'artifact-preview-caret' caret.setAttribute('aria-hidden', 'true') caret.textContent = '▸' const label = document.createElement('span') label.className = 'artifact-preview-label' label.textContent = kind === 'md' ? 'preview markdown' : 'preview page' toggle.append(caret, label) const region = document.createElement('div') region.className = 'artifact-preview-region' region.hidden = true let built = false const expand = () => { wrap.classList.add('is-open') toggle.setAttribute('aria-expanded', 'true') caret.textContent = '▾' region.hidden = false bucket().previewOpen.add(entry.artifactId) if (!built) { built = true if (kind === 'md') buildMdPreview(region, entry) else buildHtmlPreview(region, entry) } } const collapse = () => { wrap.classList.remove('is-open') toggle.setAttribute('aria-expanded', 'false') caret.textContent = '▸' region.hidden = true bucket().previewOpen.delete(entry.artifactId) } toggle.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() if (region.hidden) expand() else collapse() }) wrap.append(toggle, region) // Restore prior open state (rebuilt card / test-driven restore). // Opening the preview also means opening the parent
so the // region is visible (a closed
hides non-summary children). // Deferred to a microtask so the card is appended and `.closest` can // find the parent. if (bucket().previewOpen.has(entry.artifactId)) { Promise.resolve().then(() => { const parentCard = wrap.closest ? wrap.closest('.artifact-card') : null if (parentCard) parentCard.open = true expand() }) } return wrap } function buildMdPreview(region, entry) { const md = window.__dshMdMini const blob = latestBlob(entry) if (!md) { const note = document.createElement('div') note.className = 'artifact-preview-note muted small' note.textContent = 'markdown renderer unavailable' region.appendChild(note) return } if (typeof blob !== 'string') { // No content on the wire (real ArtifactServer path). Be honest and // point at the browser rather than fake a render. const note = document.createElement('div') note.className = 'artifact-preview-note muted small' note.textContent = '内容未随事件传入 · 用「在浏览器打开」查看' region.appendChild(note) return } const onLink = (href) => { if (window.dsh && typeof window.dsh.openExternalUrl === 'function') { window.dsh.openExternalUrl(href) } } const rendered = md.render(blob, { document, onLink }) rendered.classList.add('artifact-preview-md') region.appendChild(rendered) } function buildHtmlPreview(region, entry) { // Show a placeholder while we resolve whether a server is up; swap in // the sandboxed iframe or the fallback once known. const pending = document.createElement('div') pending.className = 'artifact-preview-note muted small' pending.textContent = 'loading preview…' region.appendChild(pending) resolveHtmlUrl(entry).then((url) => { pending.remove() if (!url) { // Server not up (e.g. stdio profile without artifacts). Offer the // existing open-in-browser action rather than a broken frame. const note = document.createElement('div') note.className = 'artifact-preview-note muted small' note.textContent = 'artifact 服务未启动 · ' const btn = document.createElement('button') btn.type = 'button' btn.className = 'artifact-open ghost small' btn.textContent = 'Open in browser' btn.addEventListener('click', (e) => { e.stopPropagation() if (btn.getAttribute('aria-disabled') === 'true') return invokeOpen(entry, btn, 'Open in browser') }) note.appendChild(btn) region.appendChild(note) return } const frame = document.createElement('iframe') frame.className = 'artifact-preview-frame' // Sandbox: allow the page's own scripts to run (many artifacts are // interactive) but withhold allow-same-origin so the framed doc can't // reach back into the 127.0.0.1 origin's storage/cookies, and grant // nothing else (no top-nav, popups, forms, downloads). frame.setAttribute('sandbox', 'allow-scripts') frame.setAttribute('loading', 'lazy') frame.setAttribute('referrerpolicy', 'no-referrer') frame.setAttribute('title', 'Artifact preview: ' + entry.artifactId) frame.src = url region.appendChild(frame) }) } function invokeOpen(entry, actionEl, restoreLabel) { 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