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 (
-