diff --git a/temp-screenshots/artifact-notice-split/after-01-failed.png b/temp-screenshots/artifact-notice-split/after-01-failed.png
new file mode 100644
index 00000000000..6a15b810b76
Binary files /dev/null and b/temp-screenshots/artifact-notice-split/after-01-failed.png differ
diff --git a/temp-screenshots/artifact-notice-split/after-02-docsilent.png b/temp-screenshots/artifact-notice-split/after-02-docsilent.png
new file mode 100644
index 00000000000..971823119ec
Binary files /dev/null and b/temp-screenshots/artifact-notice-split/after-02-docsilent.png differ
diff --git a/temp-screenshots/artifact-notice-split/before-01-failed.png b/temp-screenshots/artifact-notice-split/before-01-failed.png
new file mode 100644
index 00000000000..6e545d954ec
Binary files /dev/null and b/temp-screenshots/artifact-notice-split/before-01-failed.png differ
diff --git a/temp-screenshots/artifact-notice-split/before-02-docsilent.png b/temp-screenshots/artifact-notice-split/before-02-docsilent.png
new file mode 100644
index 00000000000..2cf0a158219
Binary files /dev/null and b/temp-screenshots/artifact-notice-split/before-02-docsilent.png differ
diff --git a/website/scripts/capture-artifact-notice-split.mjs b/website/scripts/capture-artifact-notice-split.mjs
new file mode 100644
index 00000000000..7658f41676a
--- /dev/null
+++ b/website/scripts/capture-artifact-notice-split.mjs
@@ -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: '
Quarterly report
A rendered artifact document.
',
+}
+
+/** 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: ''
+ + '
Some other page
The frame navigated here — this document is not ours and never reports a height.
'
+ + '',
+ }))
+
+ // ── 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) })
diff --git a/website/src/components/ArtifactBody.tsx b/website/src/components/ArtifactBody.tsx
index 01c28ff352c..f5fd90ab651 100644
--- a/website/src/components/ArtifactBody.tsx
+++ b/website/src/components/ArtifactBody.tsx
@@ -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 {
@@ -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 {
@@ -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.
@@ -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) && (
- {i18nT('components.artifactBody.could_not_render')}
-
+ {/* 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. */}
+
+ {i18nT(pending
+ ? 'components.artifactBody.rendering'
+ : failed
+ ? 'components.artifactBody.could_not_render'
+ : 'components.artifactBody.no_longer_showing')}
+
+ {/* 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. */}
+
+ {failed ? : }
+ {i18nT(failed
+ ? 'components.artifactBody.retry'
+ : 'components.artifactBody.show_artifact')}
+
)}
{blobUrl ? (
diff --git a/website/src/components/WidgetFrame.tsx b/website/src/components/WidgetFrame.tsx
index ab94a277038..94982485626 100644
--- a/website/src/components/WidgetFrame.tsx
+++ b/website/src/components/WidgetFrame.tsx
@@ -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'
@@ -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
@@ -566,14 +566,15 @@ export default function WidgetFrame({ html, title = 'Widget', slug, messageTs, w
{mintFailed &&
}
{/* While the document URL is in flight the row must keep the height the
diff --git a/website/src/hooks/useSandboxDoc.ts b/website/src/hooks/useSandboxDoc.ts
index b9fe0bae45c..c908709eab0 100644
--- a/website/src/hooks/useSandboxDoc.ts
+++ b/website/src/hooks/useSandboxDoc.ts
@@ -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
@@ -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(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 }
}
diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json
index 57faf3fc338..037b08e9086 100644
--- a/website/src/i18n/locales/bn.json
+++ b/website/src/i18n/locales/bn.json
@@ -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": "এজেন্ট চ্যাট",
diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json
index 70fbe98d08a..5640eb3881b 100644
--- a/website/src/i18n/locales/de.json
+++ b/website/src/i18n/locales/de.json
@@ -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",
diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json
index 2e4bca4aca3..3f7ce84af9e 100644
--- a/website/src/i18n/locales/en-XA.json
+++ b/website/src/i18n/locales/en-XA.json
@@ -5095,7 +5095,9 @@
"image_could_not_be_loaded": "[Ìɱàğè çøùĺðñ'ţ ƀè ĺøàðèð ·················]",
"rendering": "[Ŕèñðèŕìñğ… ···············]",
"retry": "[Ŕèţŕý ········]",
- "artifact": "[Àŕţìƒàçţ: {{slug}} ···············]"
+ "artifact": "[Àŕţìƒàçţ: {{slug}} ···············]",
+ "no_longer_showing": "[Ţĥìş àŕţìƒàçţ ìş ñø ĺøñğèŕ şĥøẁìñğ ·················]",
+ "show_artifact": "[Şĥøẁ àŕţìƒàçţ ············]"
},
"artifactFolderDeleteDialog": {
"cancel": "[Çàñçèĺ ·········]",
diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json
index 2c1743640ce..d02d20936bb 100644
--- a/website/src/i18n/locales/en.manual.json
+++ b/website/src/i18n/locales/en.manual.json
@@ -1357,7 +1357,9 @@
}
},
"artifactBody": {
- "artifact": "Artifact: {{slug}}"
+ "artifact": "Artifact: {{slug}}",
+ "no_longer_showing": "This artifact is no longer showing",
+ "show_artifact": "Show artifact"
},
"artifactChatPanel": {
"agent_chat": "Agent chat",
diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json
index 2d617459051..c2685f752c5 100644
--- a/website/src/i18n/locales/es.json
+++ b/website/src/i18n/locales/es.json
@@ -5282,8 +5282,10 @@
"artifact": "Artefacto: {{slug}}",
"could_not_render": "No se pudo representar este artefacto",
"image_could_not_be_loaded": "No se pudo cargar la imagen",
+ "no_longer_showing": "Este artefacto ya no se muestra",
"rendering": "Renderizando…",
- "retry": "Reintentar"
+ "retry": "Reintentar",
+ "show_artifact": "Mostrar artefacto"
},
"artifactChatPanel": {
"agent_chat": "Chat del agente",
diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json
index 16bd2a01e4b..c1efa2a8f5c 100644
--- a/website/src/i18n/locales/fr.json
+++ b/website/src/i18n/locales/fr.json
@@ -5282,8 +5282,10 @@
"artifact": "Artefact : {{slug}}",
"could_not_render": "Impossible d'afficher cet artefact",
"image_could_not_be_loaded": "Impossible de charger l'image",
+ "no_longer_showing": "Cet artefact ne s'affiche plus",
"rendering": "Rendu en cours…",
- "retry": "Réessayer"
+ "retry": "Réessayer",
+ "show_artifact": "Afficher l'artefact"
},
"artifactChatPanel": {
"agent_chat": "Discussion avec l'agent",
diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json
index e848cad84bd..2ba3dedbd2e 100644
--- a/website/src/i18n/locales/hi.json
+++ b/website/src/i18n/locales/hi.json
@@ -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": "एजेंट चैट",
diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json
index bf4c0d74471..b6d08a32855 100644
--- a/website/src/i18n/locales/it.json
+++ b/website/src/i18n/locales/it.json
@@ -5282,8 +5282,10 @@
"artifact": "Artefatto: {{slug}}",
"could_not_render": "Non è stato possibile visualizzare questo artefatto",
"image_could_not_be_loaded": "Impossibile caricare l'immagine",
+ "no_longer_showing": "Questo artefatto non è più visualizzato",
"rendering": "Visualizzazione in corso…",
- "retry": "Riprova"
+ "retry": "Riprova",
+ "show_artifact": "Mostra artefatto"
},
"artifactChatPanel": {
"agent_chat": "Chat con l’agente",
diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json
index bcbe299b713..4fc9da68401 100644
--- a/website/src/i18n/locales/ja.json
+++ b/website/src/i18n/locales/ja.json
@@ -5085,8 +5085,10 @@
"artifact": "アーティファクト:{{slug}}",
"could_not_render": "このアーティファクトを表示できませんでした",
"image_could_not_be_loaded": "画像を読み込めませんでした",
+ "no_longer_showing": "このアーティファクトは表示されなくなりました",
"rendering": "レンダリング中…",
- "retry": "再試行"
+ "retry": "再試行",
+ "show_artifact": "アーティファクトを表示"
},
"kiroAccountModal": {
"account_details_unavailable": "アカウント詳細を取得できません",
diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json
index 55aa1606da3..dd02591840b 100644
--- a/website/src/i18n/locales/ko.json
+++ b/website/src/i18n/locales/ko.json
@@ -5085,8 +5085,10 @@
"artifact": "아티팩트: {{slug}}",
"could_not_render": "이 아티팩트를 렌더링할 수 없습니다",
"image_could_not_be_loaded": "이미지를 불러올 수 없습니다",
+ "no_longer_showing": "이 아티팩트가 더 이상 표시되지 않습니다",
"rendering": "렌더링 중…",
- "retry": "다시 시도"
+ "retry": "다시 시도",
+ "show_artifact": "아티팩트 표시"
},
"kiroAccountModal": {
"account_details_unavailable": "계정 세부 정보를 불러올 수 없음",
diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json
index f43d35406ed..845ea8eabd9 100644
--- a/website/src/i18n/locales/pt.json
+++ b/website/src/i18n/locales/pt.json
@@ -5282,8 +5282,10 @@
"artifact": "Artefato: {{slug}}",
"could_not_render": "Não foi possível renderizar este artefato",
"image_could_not_be_loaded": "Não foi possível carregar a imagem",
+ "no_longer_showing": "Este artefato não é mais exibido",
"rendering": "Renderizando…",
- "retry": "Tentar novamente"
+ "retry": "Tentar novamente",
+ "show_artifact": "Mostrar artefato"
},
"artifactChatPanel": {
"agent_chat": "Chat do agente",
diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json
index 2fac33f1664..a8e7745957b 100644
--- a/website/src/i18n/locales/ru.json
+++ b/website/src/i18n/locales/ru.json
@@ -5372,8 +5372,10 @@
"artifact": "Артефакт: {{slug}}",
"could_not_render": "Не удалось отобразить этот артефакт",
"image_could_not_be_loaded": "Не удалось загрузить изображение",
+ "no_longer_showing": "Этот артефакт больше не отображается",
"rendering": "Отрисовка…",
- "retry": "Повторить"
+ "retry": "Повторить",
+ "show_artifact": "Показать артефакт"
},
"artifactChatPanel": {
"agent_chat": "Чат с агентом",
diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json
index 7b9786f0429..a9ac85e36dd 100644
--- a/website/src/i18n/locales/zh-CN.json
+++ b/website/src/i18n/locales/zh-CN.json
@@ -5102,8 +5102,10 @@
"artifact": "工件:{{slug}}",
"could_not_render": "无法渲染此工件",
"image_could_not_be_loaded": "无法加载图片",
+ "no_longer_showing": "此工件已不再显示",
"rendering": "渲染中…",
- "retry": "重试"
+ "retry": "重试",
+ "show_artifact": "显示工件"
},
"artifactChatPanel": {
"agent_chat": "智能体对话",
diff --git a/website/src/pages/RemoteArtifactDetailPage.tsx b/website/src/pages/RemoteArtifactDetailPage.tsx
index 5a0804beff7..f81cca0745a 100644
--- a/website/src/pages/RemoteArtifactDetailPage.tsx
+++ b/website/src/pages/RemoteArtifactDetailPage.tsx
@@ -209,7 +209,7 @@ export default function RemoteArtifactDetailPage() {
// A gateway-served document, not a `blob:` URL — the same reason the artifact
// and widget frames moved: some WebKit-based in-app browsers refuse a blob
// load outright and can take the whole page down with it.
- const { url: blobUrl, failed, retry } = useSandboxDoc(srcdoc)
+ const { url: blobUrl, failed, pending, retry } = useSandboxDoc(srcdoc)
// Anchored-comment highlights for the remote markdown body use the SAME
// DOM-rect overlay as the local artifact page (InlineCommentOverlay), so
@@ -366,7 +366,7 @@ export default function RemoteArtifactDetailPage() {
) : failed ? (
{i18nT('components.artifactBody.could_not_render')}
-
+
{i18nT('components.artifactBody.retry')}
diff --git a/website/src/test/ArtifactBody.iframeBlob.test.tsx b/website/src/test/ArtifactBody.iframeBlob.test.tsx
index 36f41e33d8c..bfc08d3c9cc 100644
--- a/website/src/test/ArtifactBody.iframeBlob.test.tsx
+++ b/website/src/test/ArtifactBody.iframeBlob.test.tsx
@@ -374,14 +374,18 @@ describe('ArtifactBodyIframe surfaces a frame showing something that is not ours
}))
}
- it('offers a retry when the loaded document never reports its height', async () => {
+ it('offers a Show artifact action when the loaded document never reports its height', async () => {
await loadedFrame('
silent
')
- expect(screen.queryByText(/couldn't render this artifact/i)).toBeNull()
+ expect(screen.queryByText(/no longer showing/i)).toBeNull()
await act(async () => { vi.advanceTimersByTime(4000) })
- expect(screen.getByText(/couldn't render this artifact/i)).toBeTruthy()
- expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
+ // Cause-neutral copy: from outside the opaque sandbox a spent-url 404 is
+ // indistinguishable from the reader following a link inside the artifact,
+ // so the notice must not claim a render failure (#6489).
+ expect(screen.getByText(/this artifact is no longer showing/i)).toBeTruthy()
+ expect(screen.getByRole('button', { name: /show artifact/i })).toBeTruthy()
+ expect(screen.queryByText(/couldn't render this artifact/i)).toBeNull()
// The frame stays mounted: with a document still showing, the notice overlays
// it rather than replacing what the reader may still be able to see.
expect(document.querySelector('iframe')).toBeTruthy()
@@ -395,10 +399,11 @@ describe('ArtifactBodyIframe surfaces a frame showing something that is not ours
await act(async () => { vi.advanceTimersByTime(4000) })
+ expect(screen.queryByText(/no longer showing/i)).toBeNull()
expect(screen.queryByText(/couldn't render this artifact/i)).toBeNull()
})
- it('offers a retry when the engine renavigates a spent url after a good render', async () => {
+ it('offers a Show artifact action when the engine renavigates a spent url after a good render', async () => {
// THE case this feature exists for, and the one an earlier version silently
// skipped: the document rendered and reported, then the engine navigated the
// frame again on its own (a back/forward-cache eviction) and re-requested a
@@ -407,30 +412,153 @@ describe('ArtifactBodyIframe surfaces a frame showing something that is not ours
const frame = await loadedFrame('
evicted
')
reportHeight(frame, 420)
await act(async () => { vi.advanceTimersByTime(4000) })
- expect(screen.queryByText(/couldn't render this artifact/i)).toBeNull()
+ expect(screen.queryByText(/no longer showing/i)).toBeNull()
// Same url, second load — nothing reports this time, because what the frame
// is showing is not ours.
fireEvent.load(frame)
await act(async () => { vi.advanceTimersByTime(4000) })
- expect(screen.getByText(/couldn't render this artifact/i)).toBeTruthy()
- expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
+ expect(screen.getByText(/this artifact is no longer showing/i)).toBeTruthy()
+ expect(screen.getByRole('button', { name: /show artifact/i })).toBeTruthy()
})
- it('re-mints rather than re-rendering the spent url when the retry is taken', async () => {
+ it('re-mints rather than re-rendering the spent url when Show artifact is taken', async () => {
// Re-pointing the frame at the same spent URL recovers nothing — it 404s
// again. Recovery is a fresh mint.
await loadedFrame('
silent
')
await act(async () => { vi.advanceTimersByTime(4000) })
mintSpy.mockResolvedValue({ url: '/sandbox-doc/fresh/tok' })
- screen.getByRole('button', { name: /retry/i }).click()
+ screen.getByRole('button', { name: /show artifact/i }).click()
await waitFor(() => {
expect(document.querySelector('iframe')?.getAttribute('src'))
.toBe('/sandbox-doc/fresh/tok')
})
expect(mintSpy).toHaveBeenCalledTimes(2)
- expect(screen.queryByText(/couldn't render this artifact/i)).toBeNull()
+ expect(screen.queryByText(/no longer showing/i)).toBeNull()
+ })
+
+ it('renders DIFFERENT copy for a failed mint than for a frame that stopped showing', async () => {
+ // The defect in #6489 was one notice for two states: `failed` (the mint
+ // itself failed — a failure claim is accurate) and `docSilent` (the frame
+ // may simply be showing a page the reader chose to open — a failure claim
+ // is a lie half the time). This pins the split: merging the two branches
+ // back into one message must red this test.
+ const silent = render(silent')} />)
+ const frame = await waitFor(() => {
+ const el = document.querySelector('iframe')
+ if (!el) throw new Error('frame never mounted')
+ return el as HTMLIFrameElement
+ })
+ fireEvent.load(frame)
+ await act(async () => { vi.advanceTimersByTime(4000) })
+ const silentMessage = screen.getByText(/no longer showing/i).textContent
+ const silentAction = screen.getByRole('button', { name: /show artifact/i }).textContent
+ silent.unmount()
+
+ mintSpy.mockRejectedValueOnce(new Error('gateway said no'))
+ render(doomed')} />)
+ const failedMessage = (await screen.findByText(/couldn't render this artifact/i)).textContent
+ const failedAction = screen.getByRole('button', { name: /retry/i }).textContent
+
+ expect(silentMessage).not.toBe(failedMessage)
+ expect(silentAction).not.toBe(failedAction)
+ })
+
+ it('lets failed win when both states are set at once', async () => {
+ // Reachable state: with the silent notice up, a content change re-mints and
+ // the mint FAILS. The previous url survives a failed mint (see
+ // useSandboxDoc), so no new `load` fires and docSilent stays set while
+ // failed turns on. A known failed mint is the more specific diagnosis, so
+ // its copy must win — inverting the notice ternaries to key on docSilent
+ // would keep the split but regress this order.
+ const view = render(silent')} />)
+ const frame = await waitFor(() => {
+ const el = document.querySelector('iframe')
+ if (!el) throw new Error('frame never mounted')
+ return el as HTMLIFrameElement
+ })
+ fireEvent.load(frame)
+ await act(async () => { vi.advanceTimersByTime(4000) })
+ expect(screen.getByText(/no longer showing/i)).toBeTruthy()
+
+ mintSpy.mockRejectedValueOnce(new Error('gateway said no'))
+ view.rerender(silent v2')} />)
+
+ await screen.findByText(/couldn't render this artifact/i)
+ expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
+ expect(screen.queryByText(/no longer showing/i)).toBeNull()
+ // Pin the premise: docSilent is genuinely still set here, not cleared. A
+ // second mint was attempted and NO new url landed (same iframe src, no new
+ // load), so the only docSilent-clearing path — a blobUrl change — did not
+ // run. Without these, the assertion above is equally satisfied by a code
+ // path that clears docSilent whenever failed turns on, and the test would
+ // green as a tautology.
+ expect(mintSpy).toHaveBeenCalledTimes(2)
+ expect(document.querySelector('iframe')?.getAttribute('src')).toBe(DOC_URL)
+ // The document the reader may still be looking at stays mounted throughout.
+ expect(document.querySelector('iframe')).toBeTruthy()
+ })
+
+ it('keeps the notice when a re-mint returns the same spent url', async () => {
+ // The recovery path with nothing to recover: the gateway can mint the same
+ // url string for the same html, which is a React no-op — no src change, no
+ // new load, nothing ever re-arms the silence window. If the click cleared
+ // docSilent, this outcome would leave the reader on a dead frame with NO
+ // affordance at all, strictly worse than before the click. The click is
+ // acknowledged by disabling the button while the mint is in flight, never
+ // by hiding the notice.
+ await loadedFrame('
silent
')
+ await act(async () => { vi.advanceTimersByTime(4000) })
+ expect(screen.getByText(/no longer showing/i)).toBeTruthy()
+
+ // Same-url outcome under explicit settlement control: the default mock
+ // settles inside a single act() flush, which would make the settle
+ // unobservable as a transition.
+ let settle: (v: { url: string }) => void = () => {}
+ mintSpy.mockImplementationOnce(() => new Promise((res) => { settle = res }))
+ const button = screen.getByRole('button', { name: /show artifact/i }) as HTMLButtonElement
+ button.click()
+ // Flush the passive effect first: the mint (and with it the deferred
+ // resolver) does not exist until the [srcdoc, attempt] effect runs.
+ await act(async () => { await Promise.resolve() })
+ await act(async () => { settle({ url: DOC_URL }); await Promise.resolve() })
+ // The re-enable proves the settle happened (the disabled→enabled
+ // transition itself is pinned by the dedicated test below); if a
+ // regression cleared docSilent on a same-url settle, the notice unmounts
+ // and this detached node keeps disabled=true, so this reds rather than
+ // passing on a null query.
+ await waitFor(() => expect(button.disabled).toBe(false))
+
+ expect(mintSpy).toHaveBeenCalledTimes(2)
+ expect(document.querySelector('iframe')?.getAttribute('src')).toBe(DOC_URL)
+ // The notice survives the SETTLE: it is the only affordance the reader has
+ // left, and the re-enabled button proves pending is not stuck either way.
+ expect(screen.getByText(/no longer showing/i)).toBeTruthy()
+ expect(screen.getByRole('button', { name: /show artifact/i })).toBeTruthy()
+ })
+
+ it('disables the action while the re-mint is in flight', async () => {
+ // The acknowledgment for the click: the button visibly cannot be pressed
+ // again until the attempt settles, instead of looking inert (or worse,
+ // hiding the notice before anything about the frame has changed).
+ await loadedFrame('
silent
')
+ await act(async () => { vi.advanceTimersByTime(4000) })
+
+ let settle: (v: { url: string }) => void = () => {}
+ mintSpy.mockImplementationOnce(() => new Promise((res) => { settle = res }))
+ const button = screen.getByRole('button', { name: /show artifact/i }) as HTMLButtonElement
+ button.click()
+ await act(async () => { await Promise.resolve() })
+ expect(button.disabled).toBe(true)
+
+ // Settle with the SAME url so the notice stays mounted, and assert the
+ // re-enable on the node held from before the click — settling with a fresh
+ // url would unmount the notice and let a stuck-pending regression pass on
+ // a null query.
+ await act(async () => { settle({ url: DOC_URL }); await Promise.resolve() })
+ expect(button.disabled).toBe(false)
+ expect(screen.getByText(/no longer showing/i)).toBeTruthy()
})
})