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 &&
{i18nT('components.widgetFrame.could_not_render')} - +
} {/* 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() }) })