From 73e3699347f9d350c2a6286e4f37f90f2303d83a Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Sat, 29 Aug 2026 11:07:16 +0800 Subject: [PATCH] feat(frontend): render markdown artifacts in the "open in new window" view (#5056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): render markdown artifacts in the new window The artifacts panel's "open in new window" action handed the browser the raw Gateway response. For markdown that is a `text/markdown` body the browser can only show as source, so the new window was a text dump rather than a reader. Route markdown artifacts to a new `/artifacts/view` page that renders them with the same components the panel uses (SafeStreamdown + the artifact rehype chain + citation links/panel), including the truncated-preview banner and its "load full file" action. Everything else keeps the raw Gateway URL — notably HTML/SVG, which the Gateway deliberately serves as a download so active content never executes in the application origin. - `core/artifacts/viewer.ts` centralizes which stored artifacts are markdown (`.skill` archives included, since they hold a SKILL.md), so the panel and the viewer route cannot drift. - `ArtifactFilePreview` and its siblings move out of `artifact-file-detail.tsx` into `artifact-file-preview.tsx`; otherwise the standalone route would pull the CodeMirror editor into its bundle. - The window title comes from the route's `generateMetadata`, not `document.title`, which the App Router overwrites after hydration. - The viewer reads content through `useStandaloneArtifactContent`, which shares `useArtifactContent`'s query key but not its `useThread` dependency, since a detached window has no thread context. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC * fix(frontend): keep the artifact target across re-authentication Review found the standalone viewer unrecoverable from an expired session. The window's target lives entirely in `?path=...&thread_id=...`, and both auth paths dropped it: - The layout guard redirected to `/login` with no `next` at all. A layout cannot read `searchParams`, so the guard moves into the page, which can — and rebuilds the full viewer address for `next`. The layout loses its AuthProvider along the way: nothing under this route reads `useAuth`, and the guard now makes a single `getServerSideUser` call per request. - The shared fetch wrapper built `next` from `window.location.pathname`, which silently truncated the query string. It now carries `search` too, so any route holding state in the query survives a 401, not just this one. `validateAuthNextPath` already accepts a query string. `buildArtifactViewerURL` is split out of `resolveArtifactOpenURL`: the guard needs the route itself, never the Gateway fallback that the latter takes for non-markdown targets. Tests: the login round trip (unit — the rebuilt URL survives `validateAuthNextPath` and parses back to the same target), the fetch wrapper preserving the query on 401 (unit), and the expired-session window reaching `/login` with the artifact intact (E2E). The E2E asserts on the popup's navigation *requests*, since `(auth)/layout` answers `/login` with a server redirect under DEER_FLOW_AUTH_DISABLED and no navigation commits. `tests/unit/core/models/api.test.ts` stubbed `window.location` without `search`; a real Location always has it. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC * fix(frontend): keep public showcase artifacts out of the auth gate Review found that the viewer's access check regressed `/showcase`. Those pages render with `isMock`, their artifacts are served by the unauthenticated demo route, and the raw artifact URL this window replaced stayed public — so gating the window unconditionally bounced every logged-out showcase visitor to /login for a document that is already public. `requiresAuthenticatedViewer` exempts a mock target only when `resolveStaticDemoArtifact` would actually serve it. The allowlist is the authority rather than the flag: `mock=true` is caller-supplied, so a target the demo route answers with 404 — a non-allowlisted path, or a thread that is not a demo thread — still needs a session. Covered in `tests/e2e-auth/`, since the default E2E config disables auth and cannot see this: a public showcase artifact renders without a session, while a non-allowlisted path and a missing mock flag both land on /login. Verified the positive case goes red without the exemption. Claude-Session: https://claude.ai/code/session_013AiCrC5SBc3HdFYNxsp1EC --- frontend/AGENTS.md | 2 +- frontend/src/app/artifacts/view/layout.tsx | 32 ++ frontend/src/app/artifacts/view/page.tsx | 90 ++++++ .../artifacts/artifact-file-detail.tsx | 279 ++---------------- .../artifacts/artifact-file-preview.tsx | 264 +++++++++++++++++ .../workspace/artifacts/artifact-viewer.tsx | 130 ++++++++ .../components/workspace/artifacts/index.ts | 1 + frontend/src/core/api/fetcher.ts | 7 +- frontend/src/core/artifacts/hooks.ts | 55 ++++ frontend/src/core/artifacts/viewer.ts | 141 +++++++++ frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + .../e2e-auth/artifact-viewer-showcase.spec.ts | 59 ++++ .../tests/e2e/artifact-viewer-window.spec.ts | 257 ++++++++++++++++ .../tests/unit/app/layout-boundaries.test.ts | 4 + .../artifacts/artifact-viewer.dom.test.tsx | 124 ++++++++ .../tests/unit/core/api/fetcher.dom.test.ts | 41 +++ .../tests/unit/core/artifacts/viewer.test.ts | 252 ++++++++++++++++ frontend/tests/unit/core/models/api.test.ts | 4 +- 20 files changed, 1485 insertions(+), 263 deletions(-) create mode 100644 frontend/src/app/artifacts/view/layout.tsx create mode 100644 frontend/src/app/artifacts/view/page.tsx create mode 100644 frontend/src/components/workspace/artifacts/artifact-file-preview.tsx create mode 100644 frontend/src/components/workspace/artifacts/artifact-viewer.tsx create mode 100644 frontend/src/core/artifacts/viewer.ts create mode 100644 frontend/tests/e2e-auth/artifact-viewer-showcase.spec.ts create mode 100644 frontend/tests/e2e/artifact-viewer-window.spec.ts create mode 100644 frontend/tests/unit/components/workspace/artifacts/artifact-viewer.dom.test.tsx create mode 100644 frontend/tests/unit/core/api/fetcher.dom.test.ts create mode 100644 frontend/tests/unit/core/artifacts/viewer.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7de7cbd33..4436135f9 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -50,7 +50,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat ### Source Layout (`src/`) -- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`). +- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/artifacts/view` (chrome-free window that renders one markdown artifact with the panel's own renderer), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`). - **`components/`** — React components: - `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored) - `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored) diff --git a/frontend/src/app/artifacts/view/layout.tsx b/frontend/src/app/artifacts/view/layout.tsx new file mode 100644 index 000000000..74aa1eaa7 --- /dev/null +++ b/frontend/src/app/artifacts/view/layout.tsx @@ -0,0 +1,32 @@ +import "katex/dist/katex.min.css"; +import "streamdown/styles.css"; + +import { QueryClientProvider } from "@/components/query-client-provider"; +import { I18nProvider } from "@/core/i18n/context"; +import { detectLocaleServer } from "@/core/i18n/server"; + +export const dynamic = "force-dynamic"; + +/** + * Chrome-free layout for the standalone artifact window. + * + * Deliberately not nested under `/workspace`: this route opens in its own + * browser window, so it wants the same rich-content styles as the chat page + * but none of its sidebar/thread shell. + * + * The auth guard lives in the page rather than here. A layout cannot read + * `searchParams`, and this route carries its whole target in the query string + * — guarding here would redirect to /login with nothing to come back to. + * No AuthProvider either: nothing under this route reads `useAuth`. + */ +export default async function ArtifactViewerLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const locale = await detectLocaleServer(); + + return ( + + {children} + + ); +} diff --git a/frontend/src/app/artifacts/view/page.tsx b/frontend/src/app/artifacts/view/page.tsx new file mode 100644 index 000000000..7221d2d55 --- /dev/null +++ b/frontend/src/app/artifacts/view/page.tsx @@ -0,0 +1,90 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { ArtifactViewer } from "@/components/workspace/artifacts/artifact-viewer"; +import { + artifactViewerTitle, + buildArtifactViewerURL, + parseArtifactViewerQuery, + requiresAuthenticatedViewer, + type ArtifactViewerTarget, +} from "@/core/artifacts/viewer"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever, buildLoginUrl } from "@/core/auth/types"; +import { getI18n } from "@/core/i18n/server"; + +const POST_LOGIN_FALLBACK = "/workspace"; + +type ArtifactViewerPageProps = { + searchParams: Promise>; +}; + +export async function generateMetadata({ + searchParams, +}: ArtifactViewerPageProps): Promise { + const target = parseArtifactViewerQuery(await searchParams); + return { title: artifactViewerTitle(target?.filepath) }; +} + +/** + * Gate the window, keeping the artifact reachable across a re-login. + * + * `ARTIFACT_VIEWER_ROUTE` alone identifies nothing — the target is entirely in + * the query string — so an expired session has to carry the full address into + * `next`, or the user lands on the default workspace with no way back to the + * document they opened. + */ +async function requireViewerAccess(target: ArtifactViewerTarget | null) { + if (target && !requiresAuthenticatedViewer(target)) { + return; + } + const result = await getServerSideUser(); + switch (result.tag) { + case "authenticated": + return; + case "unauthenticated": + redirect( + buildLoginUrl( + target ? buildArtifactViewerURL(target) : POST_LOGIN_FALLBACK, + ), + ); + case "needs_setup": + case "system_setup_required": + redirect("/setup"); + case "config_error": + throw new Error(result.message); + case "gateway_unavailable": + // Render anyway: the viewer surfaces its own load failure with a + // download link, which beats bouncing a detached window to a page the + // user did not ask for. + return; + default: + assertNever(result); + } +} + +export default async function ArtifactViewerPage({ + searchParams, +}: ArtifactViewerPageProps) { + const target = parseArtifactViewerQuery(await searchParams); + await requireViewerAccess(target); + + if (!target) { + const { t } = await getI18n(); + return ( +
+

+ {t.artifactPreview.missingTarget} +

+
+ ); + } + + return ( + + ); +} diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index eec396036..5717933da 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -44,41 +44,35 @@ import { reconcileArtifactDraft, } from "@/core/artifacts/editing"; import { useArtifactContent } from "@/core/artifacts/hooks"; -import { - appendHtmlPreviewBaseHref, - appendHtmlPreviewScrollRestoration, - createHtmlPreviewScrollKey, - getArtifactViewState, - HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, -} from "@/core/artifacts/preview"; +import { getArtifactViewState } from "@/core/artifacts/preview"; import { urlOfArtifact } from "@/core/artifacts/utils"; +import { + resolveArtifactOpenURL, + resolveStoredArtifactLanguage, +} from "@/core/artifacts/viewer"; import { useAuth } from "@/core/auth/AuthProvider"; -import { extractCitationSources } from "@/core/citations/sources"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { findToolCallResult } from "@/core/messages/utils"; import { installSkill, SkillRequestError } from "@/core/skills/api"; -import { - SafeStreamdown, - toStreamdownComponents, -} from "@/core/streamdown/components"; import { canBrowserPreviewFile, checkCodeFile, - getFileExtensionDisplayName, - getFileIcon, getFileName, } from "@/core/utils/files"; import { env } from "@/env"; import { cn } from "@/lib/utils"; -import { ArtifactLink } from "../citations/artifact-link"; -import { CitationSourcesPanel } from "../citations/citation-sources-panel"; import { useThread } from "../messages/context"; import { Tooltip } from "../tooltip"; +import { + ArtifactDownloadFallback, + ArtifactFilePreview, + ArtifactPreviewError, + formatArtifactBytes, +} from "./artifact-file-preview"; import { useArtifacts } from "./context"; -import { artifactMarkdownPlugins } from "./markdown-preview-plugins"; const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000; @@ -165,12 +159,13 @@ export function ArtifactFileDetail({ language ??= "text"; return { isCodeFile: true, language }; } - // Treat .skill files as markdown (they contain SKILL.md) - if (isSkillFile) { - return { isCodeFile: true, language: "markdown" }; - } - return checkCodeFile(filepath); - }, [filepath, isWriteFile, isSkillFile]); + // Shared with the standalone viewer route so both agree on which stored + // artifacts are markdown (notably .skill archives, which hold a SKILL.md). + const language = resolveStoredArtifactLanguage(filepath); + return language === null + ? { isCodeFile: false as const, language } + : { isCodeFile: true as const, language }; + }, [filepath, isWriteFile]); const canPreviewInBrowser = useMemo(() => { return canBrowserPreviewFile(filepath); }, [filepath]); @@ -543,7 +538,7 @@ export function ArtifactFileDetail({ tooltip={t.common.openInNewWindow} onClick={() => { const w = window.open( - urlOfArtifact({ filepath, threadId, isMock }), + resolveArtifactOpenURL({ filepath, threadId, isMock }), "_blank", "noopener,noreferrer", ); @@ -707,242 +702,6 @@ export function ArtifactFileDetail({ ); } -function ArtifactPreviewError({ - filepath, - threadId, - isMock, - message, - downloadLabel, -}: { - filepath: string; - threadId: string; - isMock?: boolean; - message: string; - downloadLabel: string; -}) { - return ( -
-
-

{message}

- -
-
- ); -} - -function formatArtifactBytes(bytes: number | undefined) { - if (bytes === undefined) return undefined; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; -} - -function ArtifactDownloadFallback({ - filepath, - threadId, - isMock, -}: { - filepath: string; - threadId: string; - isMock?: boolean; -}) { - const filename = getFileName(filepath); - const fileType = getFileExtensionDisplayName(filepath); - - return ( -
-
-
- {getFileIcon(filepath, "size-12")} -
-
-
{filename}
-
{fileType} file
-
-

- This file type cannot be previewed in the browser. -

- -
-
- ); -} - -export function ArtifactFilePreview({ - content, - language, - scrollKey, - url, -}: { - content: string; - language: string; - scrollKey: string; - url?: string; -}) { - const iframeRef = useRef(null); - const scrollPositionRef = useRef({ x: 0, y: 0 }); - const scrollMessageKey = useMemo( - () => createHtmlPreviewScrollKey(scrollKey), - [scrollKey], - ); - const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); - const citationSources = useMemo( - () => - language === "markdown" ? extractCitationSources(content ?? "") : [], - [content, language], - ); - - useEffect(() => { - scrollPositionRef.current = { x: 0, y: 0 }; - }, [scrollMessageKey]); - - useEffect(() => { - if (language !== "html") { - return; - } - - const handleMessage = (event: MessageEvent) => { - if (event.source !== iframeRef.current?.contentWindow) { - return; - } - if (!isArtifactScrollMessage(event.data, scrollMessageKey)) { - return; - } - - if (event.data.type === "save") { - const x = scrollCoordinate(event.data.x); - const y = scrollCoordinate(event.data.y); - if (x !== undefined && y !== undefined) { - scrollPositionRef.current = { x, y }; - } - return; - } - - iframeRef.current?.contentWindow?.postMessage( - { - source: HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, - key: scrollMessageKey, - type: "restore", - ...scrollPositionRef.current, - }, - "*", - ); - }; - - window.addEventListener("message", handleMessage); - return () => { - window.removeEventListener("message", handleMessage); - }; - }, [language, scrollMessageKey]); - - useEffect(() => { - if (language !== "html") { - setHtmlPreviewUrl(undefined); - return; - } - - const previewContent = appendHtmlPreviewScrollRestoration( - appendHtmlPreviewBaseHref(content ?? "", url), - scrollKey, - ); - const blob = new Blob([previewContent], { - type: "text/html;charset=utf-8", - }); - const objectUrl = URL.createObjectURL(blob); - setHtmlPreviewUrl(objectUrl); - - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [content, language, scrollKey, url]); - - if (language === "markdown") { - return ( -
- - {content ?? ""} - - -
- ); - } - if (language === "html") { - return ( -