diff --git a/examples/desktop/README.md b/examples/desktop/README.md index 433a378824..85f1215222 100644 --- a/examples/desktop/README.md +++ b/examples/desktop/README.md @@ -505,10 +505,17 @@ in the target profile. - **Mission Control has no persistence.** A page reload wipes the aggregate and reseeds from the next `session/list` refresh — the view is a live overlay, not a store. -- **Artifact preview opens in your default browser.** No embedded - webview, no cloud tunnel; the shell hosts a `127.0.0.1` static - server and drops a card into chat when a new file appears in the - artifact dir. +- **Artifact preview: inline peek in-stream, full view in the browser.** + Each artifact card can expand a low-key inline preview — Markdown + rendered read-only by a tiny dependency-free renderer (`md-mini.js`, + text-node-only, so any raw HTML inside the doc stays literal), and + `.html` framed in a `sandbox="allow-scripts"` iframe pointed at the + existing `127.0.0.1` server. There is still **no embedded webview for + full pages**: the inline frame is a fixed-height peek, and "Open in + browser" remains the path for the real, full-size artifact. Previews + are collapsed by default and lazy (content builds on first expand); + when the artifact server isn't up the `.html` expand falls back to the + open-in-browser action instead of a broken frame. - **Growth reads jsonl + `session/list`.** A follow-up will migrate to the `session/list` + `session/events` aggregation so events that never touch the overlay (pure chat activity) also show up. @@ -659,9 +666,12 @@ under `docs/`: `src/renderer/widgets.js` and the three header mocks. - **Artifact preview** — `127.0.0.1` static server + SSE live-reload that watches `~/Library/Application Support/dsh-desktop-demo/.artifacts/` - (override with `DSH_ARTIFACT_DIR=…`). Opens in your default browser; - no embedded webview. Tool-driven and debug-mock paths both exercised. - See `src/main/artifact-server.js`. + (override with `DSH_ARTIFACT_DIR=…`). Cards carry an inline peek — + `.md` rendered read-only by `src/renderer/md-mini.js`, `.html` framed + in a `sandbox="allow-scripts"` iframe — while the full view still opens + in your default browser (no embedded webview for full pages). + Tool-driven and debug-mock paths both exercised. See + `src/main/artifact-server.js` and `src/renderer/md-mini.js`. ### Design docs diff --git a/examples/desktop/docs/qa-artifact-inline/01-collapsed-default.png b/examples/desktop/docs/qa-artifact-inline/01-collapsed-default.png new file mode 100644 index 0000000000..b37c9141ee Binary files /dev/null and b/examples/desktop/docs/qa-artifact-inline/01-collapsed-default.png differ diff --git a/examples/desktop/docs/qa-artifact-inline/02-md-preview-expanded.png b/examples/desktop/docs/qa-artifact-inline/02-md-preview-expanded.png new file mode 100644 index 0000000000..39493714c2 Binary files /dev/null and b/examples/desktop/docs/qa-artifact-inline/02-md-preview-expanded.png differ diff --git a/examples/desktop/docs/qa-artifact-inline/03-html-iframe-expanded.png b/examples/desktop/docs/qa-artifact-inline/03-html-iframe-expanded.png new file mode 100644 index 0000000000..d31f490318 Binary files /dev/null and b/examples/desktop/docs/qa-artifact-inline/03-html-iframe-expanded.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-artifact-inline.mjs b/examples/desktop/scripts/qa-cdp-shoot-artifact-inline.mjs new file mode 100644 index 0000000000..f875301746 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-artifact-inline.mjs @@ -0,0 +1,266 @@ +// QA verification for lane-artifact-inline: the in-stream inline md/html +// artifact preview. Boots one isolated Electron on CDP :9320, seeds the +// artifact board fixture (md cards carry blob content) + fires the debug +// mock-artifact path (writes a real .html the artifact server serves), then +// captures three shots into docs/qa-artifact-inline/: +// +// 01-collapsed-default — cards present, previews collapsed (quiet rows) +// 02-md-preview-expanded — an .md card with its markdown preview open +// (headings / bold / fenced code visibly rendered) +// 03-html-iframe-expanded— the mock .html card with its sandboxed iframe +// preview open, framing the live 127.0.0.1 page +// +// Isolation mirrors scripts/qa-cdp-shoot-nav-optional.mjs (the 2026-07-18 +// postmortem pattern): tmp DSH_DESKTOP_HOME + tmp --user-data-dir, single +// CDP port, electron resolved from the PARENT repo (this worktree has no +// node_modules). DSH_QA=1 is deliberately NOT set (qa-harness auto-clicks +// onboarding). The artifact panel auto-mounts on stream init, so we drive +// the real onArtifactEvent path the production wire uses. + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync, statSync } 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_INLINE_PORT || 9320) +const OUTDIR = join(WORKTREE, 'docs/qa-artifact-inline') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) + +function seedHome(dshHome) { + const seedOverlay = [ + '# QA artifact-inline-shoot seed overlay (tmp, per-run).', + 'plugins:', + ` - "@cordisjs/plugin-include":`, + ` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`, + '', + ].join('\n') + writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay) + writeFileSync(join(dshHome, 'config.json'), + JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2)) + writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString()) +} + +async function bootElectron(dshHome, userData, port) { + const child = spawn(ELECTRON, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${userData}`, + '--disable-gpu', + '--no-sandbox', + '.', + ], { + cwd: WORKTREE, + env: { ...process.env, DSH_DESKTOP_HOME: dshHome, DSH_MAXIMIZE: '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:${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(port) { + const targets = await (await fetch(`http://localhost:${port}/json/list`)).json() + const target = targets.find(t => t.type === 'page') + if (!target) throw new Error('no page target on port ' + 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 data = typeof ev.data === 'string' ? ev.data : String(ev.data) + let msg + try { msg = JSON.parse(data) } catch { return } + 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 shoot(call, name) { + const shot = await call('Page.captureScreenshot', + { format: 'png', captureBeyondViewport: true }, 30000) + if (!shot || !shot.data) throw new Error('captureScreenshot returned no data for ' + name) + const outPath = join(OUTDIR, `${name}.png`) + writeFileSync(outPath, Buffer.from(shot.data, 'base64')) + const kb = (statSync(outPath).size / 1024).toFixed(1) + console.log(` wrote ${outPath} (${kb} KB)`) + return outPath +} + +async function main() { + const dshHome = join(tmpdir(), 'dsh-artifact-inline-home') + const userData = join(tmpdir(), 'dsh-artifact-inline-userdata') + for (const dir of [dshHome, userData]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) + } + seedHome(dshHome) + console.log(`booting on port ${CDP_PORT}`) + const { child } = await bootElectron(dshHome, userData, CDP_PORT) + const results = {} + try { + await sleep(1800) + const { call, evj } = await newCdp(CDP_PORT) + await call('Page.enable') + await call('Runtime.enable') + + // Make sure we're on the Chat view so the artifact stream/panel is live. + await evj(`(() => { + const chatBtn = document.querySelector('.tab-btn[data-tab="chat"]') + if (chatBtn) chatBtn.click() + return true + })()`) + await sleep(400) + + // Seed the board fixture through the production onArtifactEvent path — + // md entries (session.md / README.md) carry blob content so the inline + // markdown preview renders real formatting. + const seeded = await evj(`(() => { + const A = window.__dshArtifacts + const seed = window.__dshArtifactBoardSeed + if (!A || !seed) return { ok: false, reason: 'no seed/api' } + A.seedBoardFixture(seed.artifacts) + const md = seed.artifacts.filter(a => a.kind === 'md').map(a => a.artifactId) + return { ok: true, mdIds: [...new Set(md)] } + })()`) + console.log(' seeded:', JSON.stringify(seeded)) + await sleep(600) + + // --- shot 1: collapsed default (quiet rows, no preview open) ---------- + // Scroll the stream to the artifact panel first. + await evj(`(() => { + const p = document.querySelector('.artifact-panel') + if (p && p.scrollIntoView) p.scrollIntoView({ block: 'center' }) + return true + })()`) + await sleep(300) + results.collapsed = await shoot(call, '01-collapsed-default') + + // --- shot 2: md preview expanded -------------------------------------- + // Open a specific .md card's
, then click its preview toggle. + // README.md is a single-version md with headings + a fenced code block — + // the richest formatting to show. + const mdOpen = await evj(`(() => { + const card = document.querySelector('.artifact-card[data-artifact-id="README.md"]') + || document.querySelector('.artifact-card[data-artifact-id="session.md"]') + if (!card) return { ok: false, reason: 'no md card' } + card.open = true + const toggle = card.querySelector('.artifact-preview-toggle') + if (!toggle) return { ok: false, reason: 'no toggle' } + toggle.click() + const region = card.querySelector('.artifact-preview-region') + const mdBlock = card.querySelector('.artifact-preview-md') + if (card.scrollIntoView) card.scrollIntoView({ block: 'center' }) + return { + ok: true, + id: card.dataset.artifactId, + regionShown: region ? !region.hidden : null, + hasMd: !!mdBlock, + headings: mdBlock ? mdBlock.querySelectorAll('.md-mini-h').length : 0, + codeBlocks: mdBlock ? mdBlock.querySelectorAll('.md-mini-pre').length : 0, + } + })()`) + console.log(' md preview:', JSON.stringify(mdOpen)) + await sleep(400) + results.md = await shoot(call, '02-md-preview-expanded') + + // --- shot 3: html iframe expanded ------------------------------------- + // Fire the debug mock-artifact path: it writes a real mock-artifact.html + // into the artifact dir and the ArtifactServer serves it on 127.0.0.1, + // so the event carries a live `url` the sandboxed iframe can frame. + const htmlOpen = await evj(`(async () => { + if (!(window.dsh && typeof window.dsh.mockArtifact === 'function')) + return { ok: false, reason: 'no mockArtifact' } + await window.dsh.mockArtifact() + await new Promise(r => setTimeout(r, 700)) + const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]') + if (!card) return { ok: false, reason: 'no mock html card' } + card.open = true + const toggle = card.querySelector('.artifact-preview-toggle') + if (!toggle) return { ok: false, reason: 'no toggle' } + toggle.click() + if (card.scrollIntoView) card.scrollIntoView({ block: 'center' }) + return { ok: true } + })()`) + console.log(' html mock+open:', JSON.stringify(htmlOpen)) + // Wait for the iframe to actually fire `load` (cross-origin sandboxed + // frame paints async; capturing before load leaves a blank frame). + const loaded = await evj(`(async () => { + const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]') + const frame = card ? card.querySelector('.artifact-preview-frame') : null + if (!frame) return { ok: false, reason: 'no frame' } + if (frame.scrollIntoView) frame.scrollIntoView({ block: 'center' }) + await new Promise((resolve) => { + let done = false + const finish = () => { if (!done) { done = true; resolve() } } + frame.addEventListener('load', finish) + // Fallback cap in case load already fired before this listener. + setTimeout(finish, 2500) + }) + return { ok: true } + })()`) + console.log(' iframe load:', JSON.stringify(loaded)) + const frameState = await evj(`(() => { + const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]') + const frame = card ? card.querySelector('.artifact-preview-frame') : null + const note = card ? card.querySelector('.artifact-preview-note') : null + return { + hasFrame: !!frame, + sandbox: frame ? frame.getAttribute('sandbox') : null, + src: frame ? frame.src : null, + fallbackNote: note ? note.textContent.trim().slice(0, 60) : null, + } + })()`) + console.log(' iframe state:', JSON.stringify(frameState)) + // Extra beat for the framed page's own paint after load. + await sleep(900) + results.html = await shoot(call, '03-html-iframe-expanded') + + console.log('\n--- SUMMARY ---') + console.log(' md preview built :', mdOpen && mdOpen.ok, '(headings', mdOpen && mdOpen.headings, ', code', mdOpen && mdOpen.codeBlocks, ')') + console.log(' html iframe :', frameState.hasFrame, 'sandbox=', frameState.sandbox) + console.log(' shots :', Object.values(results).join(', ')) + } finally { + try { child.kill('SIGKILL') } catch {} + for (let i = 0; i < 20; i++) { + await sleep(500) + try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break } + } + } +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/examples/desktop/src/renderer/artifacts-board.js b/examples/desktop/src/renderer/artifacts-board.js index fa6fc04921..b9f258c7f2 100644 --- a/examples/desktop/src/renderer/artifacts-board.js +++ b/examples/desktop/src/renderer/artifacts-board.js @@ -165,6 +165,14 @@ } function renderTile(entry, openArtifact) { + // Board reuse decision (lane-artifact-inline, 2026-07-20): the List row + // grew an expandable inline md/html preview, but the Board tile does NOT + // reuse it. A tile is a single