795 lines
32 KiB
JavaScript
795 lines
32 KiB
JavaScript
// 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 <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`).
|
|
// 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 viewBox="0 0 20 20" width="14" height="14" 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="14" height="14" 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>',
|
|
md:
|
|
'<svg viewBox="0 0 20 20" width="14" height="14" 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>',
|
|
}
|
|
const ICON_FALLBACK =
|
|
'<svg viewBox="0 0 20 20" width="14" height="14" 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>'
|
|
|
|
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 <details>-free expandable region (we can't nest
|
|
// a <details> inside the card's <details> 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 <details> so the
|
|
// region is visible (a closed <details> 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) {
|
|
// <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 -----------------------------------------------
|
|
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 <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'
|
|
dotEl.title = 'live: SSE reload channel active'
|
|
|
|
// Right-side tiny "open ↗" link — density-spec §2: L0 actions are
|
|
// icon/link scale, not primary buttons.
|
|
const openLink = document.createElement('a')
|
|
openLink.className = 'artifact-open-link'
|
|
openLink.href = '#'
|
|
openLink.textContent = 'open ↗'
|
|
openLink.title = 'Open artifact in system browser'
|
|
openLink.setAttribute('role', 'button')
|
|
openLink.setAttribute('aria-label', `Open ${entry.artifactId} in system browser`)
|
|
openLink.addEventListener('click', (e) => {
|
|
// Prevent both the anchor navigation and the <details> toggle so
|
|
// clicking the link opens the browser without expanding the row.
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (openLink.getAttribute('aria-disabled') === 'true') return
|
|
invokeOpen(entry, openLink, 'open ↗')
|
|
})
|
|
|
|
summary.append(iconEl, nameEl, kindEl, verEl, dotEl, openLink)
|
|
|
|
// ---- 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')
|
|
pathRow.className = 'artifact-body-path'
|
|
const pathLabel = document.createElement('span')
|
|
pathLabel.className = 'artifact-body-path-label'
|
|
pathLabel.textContent = 'path'
|
|
const pathVal = document.createElement('code')
|
|
pathVal.className = 'artifact-body-path-val'
|
|
pathVal.textContent = entry.path || entry.artifactId
|
|
pathRow.append(pathLabel, pathVal)
|
|
|
|
const actionRow = document.createElement('div')
|
|
actionRow.className = 'artifact-body-actions'
|
|
const openBtn = document.createElement('button')
|
|
openBtn.type = 'button'
|
|
openBtn.className = 'artifact-open ghost small'
|
|
openBtn.textContent = 'Open in browser'
|
|
openBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation()
|
|
if (openBtn.getAttribute('aria-disabled') === 'true') return
|
|
invokeOpen(entry, openBtn, 'Open in browser')
|
|
})
|
|
actionRow.append(openBtn)
|
|
|
|
body.append(pathRow, actionRow)
|
|
|
|
// ---- inline preview (md / html) -----------------------------------
|
|
// A low-key expandable strip under the actions. Collapsed by default so
|
|
// the card stays a single quiet row; lazy — content only builds on
|
|
// first expand. Only .md and .html artifacts get it; other kinds
|
|
// (svg/binary/etc) keep the open-in-browser affordance alone.
|
|
const preview = buildPreview(entry)
|
|
if (preview) body.append(preview)
|
|
|
|
el.append(summary, body)
|
|
// Kick a fresh-flash so the arrival is noticeable.
|
|
flash(el)
|
|
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')
|
|
if (ver) ver.textContent = `v${entry.version || 1}`
|
|
const pathVal = el.querySelector('.artifact-body-path-val')
|
|
if (pathVal && entry.path) pathVal.textContent = entry.path
|
|
flash(el)
|
|
}
|
|
|
|
function flash(el) {
|
|
el.classList.add('artifact-flash')
|
|
// 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)
|
|
}
|
|
|
|
// Debug menu button — seeds the board/timeline/evolution demo. The
|
|
// real ArtifactServer broadcasts fs writes with no blob content, so
|
|
// cold-clone researchers never see the Board/Timeline/Evolution
|
|
// views' actual affordances. We route the multi-kind, multi-version
|
|
// fixture through the same onArtifactEvent entry the real wire uses
|
|
// so all three tabs light up via the production code path. When the
|
|
// seed is unavailable we fall back to the original IPC single-file
|
|
// mock.
|
|
function bindMockButton() {
|
|
const btn = document.getElementById('mock-artifact')
|
|
if (!btn) return
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true
|
|
try {
|
|
const seed = typeof window !== 'undefined' ? window.__dshArtifactBoardSeed : null
|
|
if (seed && Array.isArray(seed.artifacts) && seed.artifacts.length) {
|
|
seedBoardFixture(seed.artifacts)
|
|
} else if (window.dsh && typeof window.dsh.mockArtifact === 'function') {
|
|
await window.dsh.mockArtifact()
|
|
}
|
|
} catch (err) {
|
|
console.error('mockArtifact / seed failed', err)
|
|
} finally {
|
|
btn.disabled = false
|
|
}
|
|
})
|
|
}
|
|
|
|
// Feed the board fixture through the same onArtifactEvent path the
|
|
// real ArtifactServer uses. `seenAt` is re-anchored to "moments ago"
|
|
// so relative-time labels don't read as ~430d against the frozen
|
|
// fixture timestamps. Idempotent: ensureCard() de-dups on artifactId,
|
|
// recordHistory() de-dups on (artifactId, version).
|
|
function seedBoardFixture(list) {
|
|
if (!Array.isArray(list) || !list.length) return
|
|
const base = Date.now() - list.length * 60_000
|
|
list.forEach((raw, i) => {
|
|
onArtifactEvent({ ...raw, seenAt: base + i * 60_000 })
|
|
})
|
|
}
|
|
|
|
// Cold-clone empty state: mount the panel scaffold on stream init so
|
|
// researchers can see List/Board/Timeline tabs (and the "no artifacts
|
|
// yet" hint) exist even before the first artifact:event lands. Until
|
|
// now the panel only appeared after the first artifact — new users
|
|
// saw a blank pane and had no idea the surface existed. Scoped to the
|
|
// active session bucket so switching sessions before any artifact
|
|
// fires still gets its own empty panel per session.
|
|
function ensureEmptyPanel() {
|
|
const s = streamEl()
|
|
if (!s) return
|
|
const b = bucket()
|
|
if (b.panelEl && b.panelEl.isConnected) return
|
|
b.panelEl = buildPanel()
|
|
// Inject a muted empty-state hint into the List body. Removed on
|
|
// first appendGrouped() call so real artifacts land in a clean host.
|
|
const body = b.panelEl.querySelector('.artifact-panel-body')
|
|
if (body) {
|
|
const hint = document.createElement('div')
|
|
hint.className = 'artifact-panel-empty muted small'
|
|
hint.dataset.role = 'empty-state'
|
|
hint.textContent = 'No artifacts yet — try “artifact” in the Debug menu, or run a session that writes to .artifacts/.'
|
|
body.appendChild(hint)
|
|
}
|
|
s.appendChild(b.panelEl)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
function initDom() {
|
|
bindMockButton()
|
|
ensureEmptyPanel()
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initDom)
|
|
} else {
|
|
initDom()
|
|
}
|
|
|
|
// Route artifact events on a session id if the payload carries one.
|
|
// Real ArtifactServer broadcasts include `sessionId` on the entry when
|
|
// the writing turn was scoped to a session; fixture events also stamp
|
|
// it. When missing, we keep writing into the DEFAULT_SESSION bucket
|
|
// (early boot / debug button fixtures).
|
|
function onArtifactEvent(entry) {
|
|
if (!entry || !entry.artifactId) return
|
|
if (typeof entry.sessionId === 'string' && entry.sessionId) {
|
|
setActiveSession(entry.sessionId)
|
|
}
|
|
ensureCard(entry)
|
|
}
|
|
|
|
// 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.
|
|
// Session-scoping (P1-4): `setActiveSession(sid)` is called by the
|
|
// renderer's selectSession() to swap buckets on session switch, and
|
|
// exposed to tests that drive multiple sessions.
|
|
window.__dshArtifacts = {
|
|
onArtifactEvent,
|
|
cards,
|
|
history,
|
|
switchView,
|
|
seedBoardFixture,
|
|
ensureEmptyPanel,
|
|
getView: () => bucket().currentView,
|
|
setActiveSession,
|
|
getActiveSessionId: () => activeSessionId,
|
|
// Test hook: raw session bucket map so unit tests can assert
|
|
// isolation without threading fixtures through the DOM.
|
|
_bySession: bySession,
|
|
}
|
|
})()
|