Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 125 additions & 0 deletions website/scripts/capture-artifact-notice-split.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Screenshot harness for the artifact-frame notice split (#6489).
*
* Runs the REAL built SPA (website/dist) behind a tiny in-process static server
* and answers every /api/** call from fixtures via Playwright route interception
* (gateway-free — no kiro-cli, no live backend).
*
* The frame notice used to render ONE copy ("Couldn't render this artifact" +
* Retry) for two states: `failed` (the mint itself failed — the claim is
* accurate) and `docSilent` (the frame loaded a document that never reported a
* height — which is EITHER an engine renavigation 404ing the single-use doc URL
* or the reader deliberately following a link inside the sandbox; the two are
* indistinguishable from outside an opaque origin, so a failure claim is wrong
* half the time and "Retry" destroys a page the reader chose to open). The fix
* gives docSilent cause-neutral copy ("This artifact is no longer showing" +
* "Show artifact"); `failed` keeps its copy.
*
* Frames:
* 01-failed mint rejected — centered failure notice, no document
* 02-docsilent document loaded then went silent — overlay notice strip
*
* The point of the change is the copy split, so run against the branch (after)
* and against main (before) to see the delta.
*
* Usage: node scripts/capture-artifact-notice-split.mjs [outDir] [prefix] [distDir]
*/
import { chromium } from 'playwright'
import { mkdirSync } from 'node:fs'
import { serveDist, DEFAULT_DIST } from './lib/serve-dist.mjs'
import { logPageProblems, stubDashboardApi, json } from './lib/stub-dashboard-api.mjs'

const OUT = process.argv[2] || '../temp-screenshots/artifact-notice-split'
const PREFIX = process.argv[3] || 'after'
const DIST = process.argv[4] || DEFAULT_DIST

mkdirSync(OUT, { recursive: true })

const ARTIFACT = {
slug: 'quarterly-report',
name: 'Quarterly report',
kind: 'widget',
source: 'chat',
session_title: 'Artifact notice split',
description: 'Fixture artifact for the notice-state capture',
tags: [],
version: 1,
pinned: false,
created_at: '2026-08-20T10:00:00.000000+00:00',
updated_at: '2026-08-27T21:00:00.000000+00:00',
content: '<div style="padding:24px;font:14px system-ui"><h2>Quarterly report</h2><p>A rendered artifact document.</p></div>',
}

/** When false, POST /api/sandbox-doc 500s so the mint itself fails. */
let mintSucceeds = true

const extra = async (path, route) => {
if (path === '/api/artifacts') return json(route, { artifacts: [ARTIFACT] }), true
if (path === '/api/artifact-folders') return json(route, { folders: [] }), true
if (path === '/api/artifacts/session-docs') return json(route, { docs: [] }), true
if (path === '/api/sandbox-doc') {
if (!mintSucceeds) return json(route, { error: 'mint failed' }, 500), true
return json(route, { url: '/sandbox-doc/spent/1700000000.mac' }), true
}

const m = /^\/api\/artifacts\/([^/]+)(\/.*)?$/.exec(path)
if (!m) return false
const rest = m[2] || ''
if (rest === '/versions') return json(route, { slug: ARTIFACT.slug, versions: [1] }), true
if (rest === '/events') return json(route, { slug: ARTIFACT.slug, events: [] }), true
if (rest === '/comments') return json(route, { comments: [] }), true
if (rest === '/upstream-status') return json(route, {}), true
if (rest === '') return json(route, ARTIFACT), true
return false
}

async function main() {
const { srv, base } = await serveDist(DIST)
const browser = await chromium.launch()
const context = await browser.newContext({
viewport: { width: 1500, height: 1100 },
deviceScaleFactor: 2,
})
const page = await context.newPage()

await stubDashboardApi(page, { extra })
logPageProblems(page)

// The minted document: a page WITHOUT the injected height reporter, standing
// in for what the frame shows after it navigated away from our document (a
// spent-url 404, or a page the reader opened by following a link). It loads
// fine — `load` fires — and then never reports, which is the docSilent signal.
await page.route('**/sandbox-doc/**', route => route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: '<!doctype html><html><body style="font:14px system-ui;padding:24px;color:#444">'
+ '<h3>Some other page</h3><p>The frame navigated here — this document is not ours and never reports a height.</p>'
+ '</body></html>',
}))

// ── Frame 1: failed — the mint itself failed, failure claim is accurate ──
mintSucceeds = false
await page.goto(base + `/artifacts/${ARTIFACT.slug}`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
await page.screenshot({
path: `${OUT}/${PREFIX}-01-failed.png`,
clip: { x: 0, y: 0, width: 1500, height: 760 },
})
console.log('wrote', `${OUT}/${PREFIX}-01-failed.png`)

// ── Frame 2: docSilent — document loaded, then silence past the grace window ──
mintSucceeds = true
await page.goto(base + `/artifacts/${ARTIFACT.slug}`, { waitUntil: 'domcontentloaded' })
// DOC_REPORT_GRACE_MS is 3000ms after the frame's load event; wait past it.
await page.waitForTimeout(5500)
await page.screenshot({
path: `${OUT}/${PREFIX}-02-docsilent.png`,
clip: { x: 0, y: 0, width: 1500, height: 760 },
})
console.log('wrote', `${OUT}/${PREFIX}-02-docsilent.png`)

await browser.close()
srv.close()
}

main().catch(err => { console.error(err); process.exit(1) })
73 changes: 59 additions & 14 deletions website/src/components/ArtifactBody.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { Download, Image as ImageIcon, ImageOff, RotateCw } from 'lucide-react'
import { Download, Eye, Image as ImageIcon, ImageOff, RotateCw } from 'lucide-react'
import { useTheme } from '../hooks/useTheme'
import { useSandboxDoc } from '../hooks/useSandboxDoc'
import { useScrollMemory } from '../hooks/useScrollMemory'
import { useCommentBridge, type IframeSelection } from '../hooks/useCommentBridge'
import { InlineCommentOverlay } from './InlineCommentOverlay'
import { Btn } from './ui'
import { sanitizeCssValue } from '../lib/cssSanitize'
import { THEME_VAR_NAMES, buildSrcdoc } from '../lib/widgetSrcdoc'
import {
Expand Down Expand Up @@ -47,9 +48,12 @@ const NO_DOCUMENT_BOX_HEIGHT = 480
*
* Deliberately NOT an automatic re-mint: a second `load` also happens when a
* link inside an artifact navigates the frame, and silently pulling the reader
* back would fight an action they took. Offering the retry is the right response
* to either cause — in both, the frame has stopped showing the artifact — which
* is why the window re-arms on EVERY load rather than only on a new url. */
* back would fight an action they took. Offering the re-mint is the right
* response to either cause — in both, the frame has stopped showing the artifact
* — which is why the window re-arms on EVERY load rather than only on a new url.
* The two causes cannot be told apart from outside the opaque sandbox, so the
* notice for this state is cause-neutral ("no longer showing" / "Show artifact")
* rather than the `failed` state's failure claim. */
const DOC_REPORT_GRACE_MS = 3000

function readThemeVars(): Record<string, string> {
Expand Down Expand Up @@ -291,9 +295,11 @@ export const ArtifactBodyIframe = memo(function ArtifactBodyIframe({
)
// One shared hook rather than this effect in four components: the previous
// document survives both an in-flight and a failed re-mint, and `failed`
// clears when a retry starts. See hooks/useSandboxDoc.ts for why each rule
// clears on a successful settle (or when the srcdoc goes away) — `pending`
// acknowledges the click.
// See hooks/useSandboxDoc.ts for why each rule
// exists.
const { url: blobUrl, failed, retry } = useSandboxDoc(srcdoc)
const { url: blobUrl, failed, pending, retry } = useSandboxDoc(srcdoc)
// A new document starts the observation over. Declared before the arming
// effect below so a url change clears the previous document's verdict in the
// same commit that re-arms.
Expand Down Expand Up @@ -346,20 +352,59 @@ export const ArtifactBodyIframe = memo(function ArtifactBodyIframe({
failed re-mint must not displace what the user is reading. With no
document at all, the same overlay is a thin strip along the top of an
empty box, which reads as a broken page rather than a failed load — so
it centres instead. */}
it centres instead.

Two COPIES too, because only one of the states is a known failure.
`failed` means the mint itself failed, so asserting a render failure is
accurate and the action is honestly a retry. `docSilent` means the frame
navigated to something that is not ours, and from outside the opaque
sandbox an engine renavigation onto a spent url (a failure) is
indistinguishable from the reader following a link inside the artifact
(deliberate). The copy must not assert a failure the surface cannot
verify, and the action — the same re-mint — is labeled by what it does
for the user (bring the artifact back), not as a "retry" of an error
they may not have had. `failed` wins when both are set: a known failed
mint is the more specific diagnosis. */}
{(failed || docSilent) && (
<div
className={
blobUrl
? 'absolute top-0 left-0 right-0 z-10 px-6 py-3 flex items-center gap-3 text-text bg-bg-elevated/95 border-b border-border'
: 'absolute inset-0 z-10 flex items-center justify-center gap-3 text-text'
? 'absolute top-0 left-0 right-0 z-10 px-6 py-3 flex flex-wrap items-center gap-3 text-text bg-bg-elevated/95 border-b border-border'
: 'absolute inset-0 z-10 flex flex-wrap items-center justify-center gap-3 text-text'
}
>
<span>{i18nT('components.artifactBody.could_not_render')}</span>
<button type="button" className="btn btn-sm" onClick={retry}>
<RotateCw className="lucide-inline" />
{i18nT('components.artifactBody.retry')}
</button>
{/* The live region is the text span, not the container: a region
holding the button would re-announce the control's name as status
prose on every state flip (implicit aria-atomic). While a re-mint
is in flight it carries the existing "Rendering…" string so a
screen-reader user who pressed the action hears that something
happened — the visual disabled state alone is silent to AT. */}
<span role="status" className="min-w-0">
{i18nT(pending
? 'components.artifactBody.rendering'
: failed
? 'components.artifactBody.could_not_render'
: 'components.artifactBody.no_longer_showing')}
</span>
{/* The click is acknowledged by DISABLING the button, never by
clearing `docSilent`: a re-mint can resolve with the same url
string (a React no-op — no new `load`, so nothing would ever
re-arm the silence window) or hang, and a notice cleared at click
time would leave the reader on a dead frame with no affordance at
all. The glyph branches with the copy: a reload arrow asserts the
same failure the docSilent string just stopped claiming. Btn (the
design-system button), not a raw `btn btn-sm` button: that class
has no CSS behind it, and this control is the only recovery
affordance the reader has — it must look pressable. */}
<Btn
disabled={pending}
onClick={retry}
>
{failed ? <RotateCw className="lucide-inline" /> : <Eye className="lucide-inline" />}
{i18nT(failed
? 'components.artifactBody.retry'
: 'components.artifactBody.show_artifact')}
</Btn>
</div>
)}
{blobUrl ? (
Expand Down
13 changes: 7 additions & 6 deletions website/src/components/WidgetFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { widgetHeightKey, getWidgetHeight, setWidgetHeight, estimateWidgetHeight, clampFrameHeight } from '../utils/widgetHeights'
import { Maximize2, Minimize2, ExternalLink, Download, Star, RotateCw } from 'lucide-react'
import { IconButton, IconButtonGroup } from './ui'
import { Btn, IconButton, IconButtonGroup } from './ui'
import { useTheme } from '../hooks/useTheme'
import { sanitizeCssValue } from '../lib/cssSanitize'
import { THEME_VAR_NAMES, buildSrcdoc } from '../lib/widgetSrcdoc'
Expand Down Expand Up @@ -226,7 +226,7 @@ export default function WidgetFrame({ html, title = 'Widget', slug, messageTs, w
// See hooks/useSandboxDoc.ts: the frame loads a gateway-served document
// rather than a `blob:` URL, and the previous document survives both an
// in-flight and a failed re-mint.
const { url: blobUrl, failed: mintFailed, retry: retryMint } = useSandboxDoc(
const { url: blobUrl, failed: mintFailed, pending: mintPending, retry: retryMint } = useSandboxDoc(
visible ? srcdoc : null,
)
// Fade the iframe in once its document loads, so the reveal is a soft fade
Expand Down Expand Up @@ -566,14 +566,15 @@ export default function WidgetFrame({ html, title = 'Widget', slug, messageTs, w

{mintFailed && <div className="px-3 py-2 flex items-center gap-3 text-text">
<span>{i18nT('components.widgetFrame.could_not_render')}</span>
<button
type="button"
className="btn btn-sm"
{/* Btn, not a raw `btn btn-sm` button: that class has no CSS behind it,
so the recovery control rendered as bare text. */}
<Btn
disabled={mintPending}
onClick={retryMint}
>
<RotateCw className="lucide-inline" />
{i18nT('components.widgetFrame.retry')}
</button>
</Btn>
</div>}

{/* While the document URL is in flight the row must keep the height the
Expand Down
45 changes: 41 additions & 4 deletions website/src/hooks/useSandboxDoc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api/client'

/** How long a mint may stay pending before the pending flag is released.
*
* `pending` disables the caller's recovery button, and the mint POST is a bare
* fetch with no timeout — for a RE-MINT attempted from an already-mounted
* notice, a wedged gateway (request accepted, response never written) would
* otherwise leave that notice's only affordance disabled for the life of the
* mount. (A wedged FIRST mint shows no notice at all — `failed` never turns on
* — so the ceiling has nothing to restore there; that gap predates `pending`.)
* When the ceiling releases the flag the attempt may still settle later; both
* settle paths are idempotent, so a late arrival is harmless. */
const MINT_PENDING_CEILING_MS = 15_000

/** Mint a gateway-served document URL for model-authored HTML shown in an iframe.
*
* Every surface that renders artifact or widget HTML goes through here rather
Expand All @@ -18,47 +30,72 @@ import { api } from '../api/client'
* - The previous url also survives a FAILED mint. A transient blip while the
* user is reading must not replace a document that is rendering fine; the
* caller shows the failure notice alongside it instead.
* - `failed` clears when a retry STARTS, so the surface visibly acknowledges
* the click instead of staying pixel-identical until the attempt lands.
* - `failed` clears on a successful settle, or when `srcdoc` goes away (which
* tears the whole state down) — never at retry start. Clearing at start
* flips a caller's failure notice to whatever its
* other states show (or unmounts it) before anything about the frame has
* changed — the accessible name of the control the user just pressed must
* not change mid-flight. `pending` is what acknowledges the click.
*/
export function useSandboxDoc(srcdoc: string | null | undefined): {
/** The minted document URL, or null before the first one lands. */
url: string | null
/** The last mint attempt failed. `url` may still hold a working document. */
failed: boolean
/** A mint is in flight. Callers use this to disable their recovery action:
* a re-mint can resolve with the SAME url string (a React no-op that fires
* no new `load`), so a caller that hides its notice at click time can end up
* with no affordance at all if nothing observable changes. Disabling on
* `pending` acknowledges the click without removing the notice. */
pending: boolean
/** Mint again. Required for recovery: the URL is single-use server-side, so
* re-rendering a spent one recovers nothing. */
retry: () => void
} {
const [url, setUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const [pending, setPending] = useState(false)
const [attempt, setAttempt] = useState(0)

useEffect(() => {
if (!srcdoc) {
setUrl(null)
setFailed(false)
setPending(false)
return
}
let alive = true
setFailed(false)
// Deliberately NOT setFailed(false) here: failed clears only on a
// successful settle. See the third rule in the header comment.
setPending(true)
// A wedged POST would otherwise pin `pending` (and the caller's disabled
// recovery button) for the life of the mount. Releasing the flag does not
// abort the attempt; the settle paths below remain valid if it lands late.
const ceiling = setTimeout(() => {
if (alive) setPending(false)
}, MINT_PENDING_CEILING_MS)
api
.sandboxDocUrl(srcdoc)
.then((r) => {
if (!alive) return
clearTimeout(ceiling)
setUrl(r.url)
setFailed(false)
setPending(false)
})
.catch(() => {
if (!alive) return
clearTimeout(ceiling)
// The previous url is deliberately left in place — see the contract above.
setFailed(true)
setPending(false)
})
return () => {
alive = false
clearTimeout(ceiling)
}
}, [srcdoc, attempt])

const retry = useCallback(() => setAttempt((n) => n + 1), [])
return { url, failed, retry }
return { url, failed, pending, retry }
}
4 changes: 3 additions & 1 deletion website/src/i18n/locales/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -5192,8 +5192,10 @@
"artifact": "আর্টিফ্যাক্ট: {{slug}}",
"could_not_render": "এই আর্টিফ্যাক্টটি রেন্ডার করা যায়নি",
"image_could_not_be_loaded": "ছবিটি লোড করা যায়নি",
"no_longer_showing": "এই আর্টিফ্যাক্টটি আর দেখা যাচ্ছে না",
"rendering": "রেন্ডার হচ্ছে…",
"retry": "আবার চেষ্টা করুন"
"retry": "আবার চেষ্টা করুন",
"show_artifact": "আর্টিফ্যাক্ট দেখান"
},
"artifactChatPanel": {
"agent_chat": "এজেন্ট চ্যাট",
Expand Down
4 changes: 3 additions & 1 deletion website/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5192,8 +5192,10 @@
"artifact": "Artefakt: {{slug}}",
"could_not_render": "Dieses Artefakt konnte nicht dargestellt werden",
"image_could_not_be_loaded": "Bild konnte nicht geladen werden",
"no_longer_showing": "Dieses Artefakt wird nicht mehr angezeigt",
"rendering": "Wird gerendert…",
"retry": "Wiederholen"
"retry": "Wiederholen",
"show_artifact": "Artefakt anzeigen"
},
"artifactChatPanel": {
"agent_chat": "Agenten-Chat",
Expand Down
Loading
Loading