feat(frontend): render markdown artifacts in the "open in new window" view (#5056)

* 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
This commit is contained in:
Nan Gao 2026-08-29 11:07:16 +08:00 committed by GitHub
parent 0cb356858b
commit 73e3699347
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1485 additions and 263 deletions

View File

@ -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)

View File

@ -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 (
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
);
}

View File

@ -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<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({
searchParams,
}: ArtifactViewerPageProps): Promise<Metadata> {
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 (
<main className="flex h-screen items-center justify-center p-6">
<p className="text-muted-foreground text-sm">
{t.artifactPreview.missingTarget}
</p>
</main>
);
}
return (
<ArtifactViewer
filepath={target.filepath}
threadId={target.threadId}
isMock={target.isMock}
/>
);
}

View File

@ -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 (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">{message}</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
{downloadLabel}
</a>
</Button>
</div>
</div>
);
}
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 (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="text-muted-foreground">
{getFileIcon(filepath, "size-12")}
</div>
<div className="space-y-1">
<div className="font-medium break-all">{filename}</div>
<div className="text-muted-foreground text-sm">{fileType} file</div>
</div>
<p className="text-muted-foreground text-sm">
This file type cannot be previewed in the browser.
</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
Download
</a>
</Button>
</div>
</div>
);
}
export function ArtifactFilePreview({
content,
language,
scrollKey,
url,
}: {
content: string;
language: string;
scrollKey: string;
url?: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const scrollPositionRef = useRef({ x: 0, y: 0 });
const scrollMessageKey = useMemo(
() => createHtmlPreviewScrollKey(scrollKey),
[scrollKey],
);
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
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 (
<div className="size-full overflow-auto px-4 py-3">
<SafeStreamdown
className="min-w-0"
{...artifactMarkdownPlugins}
components={toStreamdownComponents({ a: ArtifactLink })}
>
{content ?? ""}
</SafeStreamdown>
<CitationSourcesPanel sources={citationSources} className="mb-4" />
</div>
);
}
if (language === "html") {
return (
<iframe
ref={iframeRef}
className="size-full"
title="Artifact preview"
// allow-scripts is needed for the scroll-restoration injected
// script (appendHtmlPreviewScrollRestoration) which communicates
// via postMessage. allow-same-origin is deliberately omitted: the
// opaque origin prevents access to parent.document and cookies,
// and postMessage(..., "*") works fine from it.
sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl}
/>
);
}
return null;
}
function isArtifactScrollMessage(
data: unknown,
key: string,
): data is {
type: "save" | "restore-request";
x?: unknown;
y?: unknown;
} {
return (
typeof data === "object" &&
data !== null &&
"source" in data &&
data.source === HTML_PREVIEW_SCROLL_MESSAGE_SOURCE &&
"key" in data &&
data.key === key &&
"type" in data &&
(data.type === "save" || data.type === "restore-request")
);
}
function scrollCoordinate(value: unknown) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function useThrottledValue(
value: string,
intervalMs: number,

View File

@ -0,0 +1,264 @@
"use client";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
createHtmlPreviewScrollKey,
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
} from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { extractCitationSources } from "@/core/citations/sources";
import {
SafeStreamdown,
toStreamdownComponents,
} from "@/core/streamdown/components";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { ArtifactLink } from "../citations/artifact-link";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
export function ArtifactPreviewError({
filepath,
threadId,
isMock,
message,
downloadLabel,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
message: string;
downloadLabel: string;
}) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">{message}</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
{downloadLabel}
</a>
</Button>
</div>
</div>
);
}
export 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`;
}
export function ArtifactDownloadFallback({
filepath,
threadId,
isMock,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
}) {
const filename = getFileName(filepath);
const fileType = getFileExtensionDisplayName(filepath);
return (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="text-muted-foreground">
{getFileIcon(filepath, "size-12")}
</div>
<div className="space-y-1">
<div className="font-medium break-all">{filename}</div>
<div className="text-muted-foreground text-sm">{fileType} file</div>
</div>
<p className="text-muted-foreground text-sm">
This file type cannot be previewed in the browser.
</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
Download
</a>
</Button>
</div>
</div>
);
}
export function ArtifactFilePreview({
content,
language,
scrollKey,
url,
}: {
content: string;
language: string;
scrollKey: string;
url?: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const scrollPositionRef = useRef({ x: 0, y: 0 });
const scrollMessageKey = useMemo(
() => createHtmlPreviewScrollKey(scrollKey),
[scrollKey],
);
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
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 (
<div className="size-full overflow-auto px-4 py-3">
<SafeStreamdown
className="min-w-0"
{...artifactMarkdownPlugins}
components={toStreamdownComponents({ a: ArtifactLink })}
>
{content ?? ""}
</SafeStreamdown>
<CitationSourcesPanel sources={citationSources} className="mb-4" />
</div>
);
}
if (language === "html") {
return (
<iframe
ref={iframeRef}
className="size-full"
title="Artifact preview"
// allow-scripts is needed for the scroll-restoration injected
// script (appendHtmlPreviewScrollRestoration) which communicates
// via postMessage. allow-same-origin is deliberately omitted: the
// opaque origin prevents access to parent.document and cookies,
// and postMessage(..., "*") works fine from it.
sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl}
/>
);
}
return null;
}
function isArtifactScrollMessage(
data: unknown,
key: string,
): data is {
type: "save" | "restore-request";
x?: unknown;
y?: unknown;
} {
return (
typeof data === "object" &&
data !== null &&
"source" in data &&
data.source === HTML_PREVIEW_SCROLL_MESSAGE_SOURCE &&
"key" in data &&
data.key === key &&
"type" in data &&
(data.type === "save" || data.type === "restore-request")
);
}
function scrollCoordinate(value: unknown) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}

View File

@ -0,0 +1,130 @@
"use client";
import { DownloadIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useStandaloneArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { getFileIcon, getFileName } from "@/core/utils/files";
import {
formatArtifactBytes,
ArtifactFilePreview,
} from "./artifact-file-preview";
/**
* Standalone markdown artifact window.
*
* The artifacts panel's "open in new window" action used to hand the browser
* the raw Gateway response, which shows markdown as its own source. This
* renders it with the same components the panel uses, so the new window is a
* reader rather than a text dump. Markdown only HTML and SVG artifacts stay
* on the Gateway's download path so active content never runs in this origin.
*/
export function ArtifactViewer({
filepath,
threadId,
isMock = false,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
}) {
const { t } = useI18n();
const filename = getFileName(filepath);
const {
content,
url,
truncated,
previewBytes,
totalBytes,
fullContentRequested,
loadFullContent,
isLoading,
error,
} = useStandaloneArtifactContent({ filepath, threadId, isMock });
const isLoadingFullContent = fullContentRequested && isLoading;
return (
<div className="bg-background flex h-screen flex-col">
<header className="border-border bg-background/95 sticky top-0 z-10 flex shrink-0 items-center gap-3 border-b px-4 py-3 backdrop-blur">
<div className="text-muted-foreground shrink-0">
{getFileIcon(filepath, "size-4")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium" title={filepath}>
{filename}
</div>
<div className="text-muted-foreground truncate text-xs">
{filepath}
</div>
</div>
<Button variant="ghost" size="sm" asChild>
<a
href={urlOfArtifact({ filepath, threadId, isMock })}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="size-4" />
{t.artifactPreview.viewSource}
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
isMock,
download: true,
})}
>
<DownloadIcon className="size-4" />
{t.common.download}
</a>
</Button>
</header>
{truncated && (
<div className="border-border bg-muted/40 flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2 text-sm">
<span className="text-muted-foreground">
{t.artifactPreview.limited(
formatArtifactBytes(previewBytes) ?? "1 MiB",
formatArtifactBytes(totalBytes),
)}
</span>
<Button size="sm" variant="outline" onClick={loadFullContent}>
{t.artifactPreview.loadFullFile}
</Button>
</div>
)}
{isLoadingFullContent && (
<div className="border-border text-muted-foreground flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm">
<LoaderIcon className="size-4 animate-spin" />
{t.artifactPreview.loadingFullFile}
</div>
)}
<main className="mx-auto min-h-0 w-full max-w-4xl flex-1 overflow-hidden">
{error ? (
<p className="text-muted-foreground p-6 text-sm">
{t.artifactPreview.previewFailed}
</p>
) : content === undefined ? (
<div className="text-muted-foreground flex items-center gap-2 p-6 text-sm">
<LoaderIcon className="size-4 animate-spin" />
{t.common.loading}
</div>
) : (
<ArtifactFilePreview
content={content}
language="markdown"
scrollKey={filepath}
url={url}
/>
)}
</main>
</div>
);
}

View File

@ -1,4 +1,5 @@
export * from "./artifact-file-detail";
export * from "./artifact-file-preview";
export * from "./artifact-file-list";
export * from "./artifact-trigger";
export * from "./context";

View File

@ -83,7 +83,12 @@ export async function fetch(
});
if (res.status === 401) {
window.location.href = buildLoginUrl(window.location.pathname);
// Include the search string: routes that carry their target in the query
// (e.g. the standalone artifact viewer) are otherwise unrecoverable after
// login, which lands on the default workspace instead.
window.location.href = buildLoginUrl(
`${window.location.pathname}${window.location.search}`,
);
throw new UnauthorizedError();
}

View File

@ -75,3 +75,58 @@ export function useArtifactContent({
error,
};
}
/**
* Artifact content for the standalone viewer route.
*
* Deliberately separate from ``useArtifactContent``: that hook reads the live
* thread stream through ``useThread`` so it can refetch when a run settles,
* and the viewer window has no thread context to read. The query key matches
* so both share the cache when they happen to run in the same document.
*/
export function useStandaloneArtifactContent({
filepath,
threadId,
isMock = false,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
}) {
const [fullContentSelection, setFullContentSelection] = useState<{
filepath: string;
threadId: string;
} | null>(null);
const fullContentRequested =
fullContentSelection?.filepath === filepath &&
fullContentSelection.threadId === threadId;
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
queryFn: () =>
loadArtifactContent({
filepath,
threadId,
isMock,
full: fullContentRequested,
}),
staleTime: 0,
refetchOnWindowFocus: true,
});
const loadFullContent = useCallback(() => {
setFullContentSelection({ filepath, threadId });
}, [filepath, threadId]);
return {
content: data?.content,
url: data?.url,
truncated: data?.truncated ?? false,
previewBytes: data?.previewBytes,
totalBytes: data?.totalBytes,
fullContentRequested,
loadFullContent,
isLoading,
error,
};
}

View File

@ -0,0 +1,141 @@
import { resolveStaticDemoArtifact } from "@/core/threads/static-demo";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { urlOfArtifact } from "./utils";
/** Standalone route that renders a stored artifact with the app's own renderer. */
export const ARTIFACT_VIEWER_ROUTE = "/artifacts/view";
export type ArtifactViewerTarget = {
filepath: string;
threadId: string;
isMock: boolean;
};
/**
* Language the artifacts panel renders a *stored* artifact with.
*
* `.skill` archives are ZIPs whose `SKILL.md` member is what the panel loads
* (see `loadArtifactContent`), so they render as markdown like the panel does.
* Kept here rather than inlined in the panel so the standalone viewer and the
* panel cannot drift on what "this is markdown" means.
*/
export function resolveStoredArtifactLanguage(filepath: string) {
if (filepath.endsWith(".skill")) {
return "markdown";
}
return checkCodeFile(filepath).language;
}
/**
* Target for the artifacts panel's "open in new window" action.
*
* Markdown goes to the in-app viewer route, which renders it with the same
* components as the panel instead of handing the browser a `text/markdown`
* response it can only show as raw source. 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.
*/
export function resolveArtifactOpenURL({
filepath,
threadId,
isMock = false,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
}) {
if (resolveStoredArtifactLanguage(filepath) !== "markdown") {
return urlOfArtifact({ filepath, threadId, isMock });
}
return buildArtifactViewerURL({ filepath, threadId, isMock });
}
/**
* Address of the viewer window for *target*.
*
* Unlike `resolveArtifactOpenURL` this never falls back to the Gateway URL:
* callers that already are the viewer window the auth guard rebuilding its
* own address for a post-login return need the route itself.
*/
export function buildArtifactViewerURL({
filepath,
threadId,
isMock,
}: ArtifactViewerTarget) {
const params = new URLSearchParams({ path: filepath, thread_id: threadId });
if (isMock) {
params.set("mock", "true");
}
return `${ARTIFACT_VIEWER_ROUTE}?${params.toString()}`;
}
/** Read a viewer target back out of the route's query string. */
export function parseArtifactViewerParams(
params: URLSearchParams,
): ArtifactViewerTarget | null {
const filepath = params.get("path")?.trim();
const threadId = params.get("thread_id")?.trim();
if (!filepath || !threadId) {
return null;
}
return { filepath, threadId, isMock: params.get("mock") === "true" };
}
/**
* Parse a Next.js `searchParams` record into a viewer target.
*
* A repeated query parameter arrives as an array; take the first value rather
* than letting a second `?path=` appended to a shared link decide what the
* window loads.
*/
export function parseArtifactViewerQuery(
query: Record<string, string | string[] | undefined> | undefined,
): ArtifactViewerTarget | null {
if (!query) {
return null;
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
const first = Array.isArray(value) ? value[0] : value;
if (first !== undefined) {
params.set(key, first);
}
}
return parseArtifactViewerParams(params);
}
/**
* Browser-tab title for the viewer window.
*
* Applied through the route's `generateMetadata`, not `document.title`: the
* App Router owns the title element and re-applies the layout's metadata over
* anything an effect writes.
*/
export function artifactViewerTitle(filepath: string | undefined) {
return filepath ? `${getFileName(filepath)} - DeerFlow` : "DeerFlow";
}
/**
* Whether the viewer window has to sit behind the user-auth gate.
*
* Public `/showcase` threads render with `isMock`, and their artifacts are
* served by the unauthenticated demo route, which answers only for an
* allowlisted set of files. Gating those would bounce every logged-out
* showcase visitor to /login for a document that is already public and the
* raw artifact URL this window replaced stayed reachable.
*
* The allowlist is the authority, not the flag: `mock=true` is caller-supplied
* and on its own grants nothing, because a target the demo route would answer
* with 404 still needs a session.
*/
export function requiresAuthenticatedViewer(target: ArtifactViewerTarget) {
if (!target.isMock) {
return true;
}
const segments = target.filepath
.replace(/^\/+/, "")
.split("/")
.map((segment) => encodeURIComponent(segment));
return resolveStaticDemoArtifact(target.threadId, segments) === null;
}

View File

@ -124,6 +124,8 @@ export const enUS: Translations = {
loadingFullFile: "Loading full file...",
previewFailed:
"This file could not be previewed. You can still download it.",
viewSource: "View source",
missingTarget: "This link does not say which artifact to display.",
},
// Citations

View File

@ -102,6 +102,8 @@ export interface Translations {
loadFullFile: string;
loadingFullFile: string;
previewFailed: string;
viewSource: string;
missingTarget: string;
};
// Citations

View File

@ -121,6 +121,8 @@ export const zhCN: Translations = {
loadFullFile: "加载完整文件",
loadingFullFile: "正在加载完整文件...",
previewFailed: "无法预览此文件,但仍可下载原始文件。",
viewSource: "查看原始文件",
missingTarget: "该链接没有指明要展示哪个文件。",
},
// Citations

View File

@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
/**
* The default E2E config runs with auth disabled, so it cannot see this: the
* standalone artifact window must stay reachable for a logged-out visitor when
* the target is a public showcase artifact, and must not for anything else.
*/
// An allowlisted showcase artifact — see STATIC_DEMO_ARTIFACTS.
const DEMO_THREAD_ID = "3823e443-4e2b-4679-b496-a9506eae462b";
const DEMO_ARTIFACT = "/mnt/user-data/outputs/fei-fei-li-podcast-timeline.md";
function viewerUrl(params: Record<string, string>) {
return `/artifacts/view?${new URLSearchParams(params).toString()}`;
}
test.describe("standalone artifact viewer access", () => {
test("renders a public showcase artifact without a session", async ({
page,
}) => {
await page.goto(
viewerUrl({
path: DEMO_ARTIFACT,
thread_id: DEMO_THREAD_ID,
mock: "true",
}),
);
await expect(page).toHaveURL(/\/artifacts\/view/);
await expect(
page.getByText("fei-fei-li-podcast-timeline.md").first(),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator("h1").first()).toBeVisible({ timeout: 15_000 });
});
test("sends a logged-out visitor to login for a non-public artifact", async ({
page,
}) => {
await page.goto(
viewerUrl({
path: "/mnt/user-data/outputs/private-notes.md",
thread_id: DEMO_THREAD_ID,
mock: "true",
}),
);
await expect(page).toHaveURL(/\/login\?next=/, { timeout: 15_000 });
});
test("sends a logged-out visitor to login when the mock flag is absent", async ({
page,
}) => {
await page.goto(
viewerUrl({ path: DEMO_ARTIFACT, thread_id: DEMO_THREAD_ID }),
);
await expect(page).toHaveURL(/\/login\?next=/, { timeout: 15_000 });
});
});

View File

@ -0,0 +1,257 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
/**
* Thread endpoints the workspace shell polls that `mockLangGraphAPI` does not
* cover. Left unmocked they reach a real Gateway when one happens to be
* running locally, and its 401 bounces the page to /login before the artifact
* panel ever opens. Answer with the same 5xx a missing backend produces, which
* these panels already tolerate.
*/
async function stubUnmockedThreadEndpoints(page: Page, threadId: string) {
for (const suffix of ["token-usage", "mcp-tasks**"]) {
await page.route(`**/api/threads/${threadId}/${suffix}`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: "{}",
}),
);
}
}
const MARKDOWN_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.md";
const HTML_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.html";
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003130";
const HTML_THREAD_ID = "00000000-0000-0000-0000-000000003131";
const EXPIRED_THREAD_ID = "00000000-0000-0000-0000-000000003132";
const ARTIFACT_VIEWER_PATH = "/artifacts/view";
function presentFilesMessages(path: string) {
return [
{
type: "human",
id: "msg-human-present-file",
content: [{ type: "text", text: "Create a report" }],
},
{
type: "ai",
id: "msg-ai-present-file",
content: "The report has been written. Now let me present the file.",
tool_calls: [
{
id: "present-file-artifact",
name: "present_files",
args: { filepaths: [path] },
},
],
},
];
}
test.describe("Artifact viewer window", () => {
test("renders a markdown artifact instead of its source in the new window", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MARKDOWN_THREAD_ID,
title: "Markdown artifact viewer window",
messages: presentFilesMessages(MARKDOWN_ARTIFACT_PATH),
artifacts: [MARKDOWN_ARTIFACT_PATH],
},
],
});
await stubUnmockedThreadEndpoints(page, MARKDOWN_THREAD_ID);
// Registered on the context, not the page: the detached viewer window
// fetches the same artifact and page routes do not reach it.
await page
.context()
.route(
`**/api/threads/${MARKDOWN_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`,
(route) =>
route.fulfill({
status: 200,
contentType: "text/markdown",
body: "# Quarterly Report\n\n测试内容 1\n",
}),
);
await page.goto(`/workspace/chats/${MARKDOWN_THREAD_ID}`);
await expect(page.getByText("presented-report.md")).toBeVisible({
timeout: 15_000,
});
await page.getByText("presented-report.md").first().click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("Quarterly Report")).toBeVisible();
const viewerPromise = page.context().waitForEvent("page");
await artifactsPanel
.getByRole("button", { name: "Open in new window" })
.click();
const viewer = await viewerPromise;
await viewer.waitForLoadState("domcontentloaded");
await expect
.poll(() => new URL(viewer.url()).pathname)
.toBe("/artifacts/view");
const params = new URL(viewer.url()).searchParams;
expect(params.get("path")).toBe(MARKDOWN_ARTIFACT_PATH);
expect(params.get("thread_id")).toBe(MARKDOWN_THREAD_ID);
await expect(
viewer.getByRole("heading", { name: "Quarterly Report" }),
).toBeVisible({ timeout: 15_000 });
await expect(viewer.getByText("测试内容 1")).toBeVisible();
// The raw markdown source must not be what the window shows.
await expect(viewer.getByText("# Quarterly Report")).toHaveCount(0);
await expect(viewer).toHaveTitle(/presented-report\.md/);
await viewer.close();
});
test("keeps html artifacts on the gateway URL so they stay downloads", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: HTML_THREAD_ID,
title: "Html artifact viewer window",
messages: presentFilesMessages(HTML_ARTIFACT_PATH),
artifacts: [HTML_ARTIFACT_PATH],
},
],
});
await stubUnmockedThreadEndpoints(page, HTML_THREAD_ID);
await page
.context()
.route(
`**/api/threads/${HTML_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.html`,
(route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><html><body><h1>Report draft</h1></body></html>",
}),
);
await page.goto(`/workspace/chats/${HTML_THREAD_ID}`);
await expect(page.getByText("presented-report.html")).toBeVisible({
timeout: 15_000,
});
await page.getByText("presented-report.html").first().click();
const artifactsPanel = page.locator("#artifacts");
await expect(
artifactsPanel.locator('iframe[title="Artifact preview"]'),
).toBeVisible();
const openedPromise = page.context().waitForEvent("page");
await artifactsPanel
.getByRole("button", { name: "Open in new window" })
.click();
const opened = await openedPromise;
await expect
.poll(() => new URL(opened.url()).pathname)
.toBe(
`/api/threads/${HTML_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.html`,
);
await opened.close();
});
test("returns to the same artifact after an expired session", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: EXPIRED_THREAD_ID,
title: "Expired session viewer window",
messages: presentFilesMessages(MARKDOWN_ARTIFACT_PATH),
artifacts: [MARKDOWN_ARTIFACT_PATH],
},
],
});
await stubUnmockedThreadEndpoints(page, EXPIRED_THREAD_ID);
// The session is valid for the panel and lapsed for the detached window,
// keyed off the requesting frame so the panel's own refetches cannot race
// for the 401.
await page
.context()
.route(
`**/api/threads/${EXPIRED_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`,
(route) =>
route.request().frame().url().includes(ARTIFACT_VIEWER_PATH)
? route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "Not authenticated" }),
})
: route.fulfill({
status: 200,
contentType: "text/markdown; charset=utf-8",
body: "# Quarterly Report\n",
}),
);
// Record the popup's navigation *requests*, not its committed URLs. This
// harness runs with DEER_FLOW_AUTH_DISABLED, so `(auth)/layout` treats the
// window as signed in and answers /login with a server redirect that never
// commits — the request is the only place the redirect target is visible.
const navigated: string[] = [];
page.context().on("page", (opened) => {
opened.on("request", (request) => {
if (request.isNavigationRequest()) {
navigated.push(request.url());
}
});
});
await page.goto(`/workspace/chats/${EXPIRED_THREAD_ID}`);
await expect(page.getByText("presented-report.md")).toBeVisible({
timeout: 15_000,
});
await page.getByText("presented-report.md").first().click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("Quarterly Report")).toBeVisible();
const viewerPromise = page.context().waitForEvent("page");
await artifactsPanel
.getByRole("button", { name: "Open in new window" })
.click();
const viewer = await viewerPromise;
// `about:blank` can appear here and `new URL` throws on an empty string,
// so parse defensively — otherwise the poll predicate errors out instead
// of retrying.
const findLogin = () =>
navigated.find((url) => {
try {
return new URL(url).pathname === "/login";
} catch {
return false;
}
});
await expect.poll(findLogin, { timeout: 15_000 }).toBeDefined();
const loginUrl = new URL(findLogin()!);
const next = loginUrl.searchParams.get("next");
expect(next).not.toBe(null);
// The whole viewer address travels through login — query string included —
// so the user comes back to this artifact, not the default workspace.
const returned = new URL(next!, loginUrl.origin);
expect(returned.pathname).toBe(ARTIFACT_VIEWER_PATH);
expect(returned.searchParams.get("path")).toBe(MARKDOWN_ARTIFACT_PATH);
expect(returned.searchParams.get("thread_id")).toBe(EXPIRED_THREAD_ID);
await viewer.close();
});
});

View File

@ -30,12 +30,16 @@ describe("layout performance boundaries", () => {
expect(source("src/app/blog/layout.tsx")).toContain(
'import "katex/dist/katex.min.css"',
);
const artifactViewerLayout = source("src/app/artifacts/view/layout.tsx");
expect(artifactViewerLayout).toContain('import "streamdown/styles.css"');
expect(artifactViewerLayout).toContain('import "katex/dist/katex.min.css"');
});
it("passes only serializable locale state through server layouts", () => {
for (const layout of [
source("src/app/(auth)/layout.tsx"),
source("src/app/workspace/layout.tsx"),
source("src/app/artifacts/view/layout.tsx"),
]) {
expect(layout).toContain("detectLocaleServer");
expect(layout).not.toContain("initialTranslations");

View File

@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
rs.mock("@/core/artifacts/loader", () => ({
loadArtifactContent: rs.fn(),
loadArtifactContentFromToolCall: rs.fn(),
}));
import { ArtifactViewer } from "@/components/workspace/artifacts/artifact-viewer";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { I18nProvider } from "@/core/i18n/context";
const mockedLoadArtifactContent = rs.mocked(loadArtifactContent);
const filepath = "/mnt/user-data/outputs/report.md";
const threadId = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
function Wrapper({ children }: PropsWithChildren) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en-US">{children}</I18nProvider>
</QueryClientProvider>
);
}
function renderViewer(props?: { isMock?: boolean }) {
return render(
<Wrapper>
<ArtifactViewer filepath={filepath} threadId={threadId} {...props} />
</Wrapper>,
);
}
describe("ArtifactViewer", () => {
beforeEach(() => {
mockedLoadArtifactContent.mockResolvedValue({
content: "# Quarterly report\n\nRevenue is up.",
url: urlOfArtifact({ filepath, threadId }),
sha256: undefined,
truncated: false,
previewBytes: 36,
totalBytes: 36,
});
});
afterEach(() => {
cleanup();
mockedLoadArtifactContent.mockReset();
});
it("renders the markdown artifact with the app's markdown renderer", async () => {
const { container } = renderViewer();
await waitFor(() => {
expect(container.querySelector("h1")?.textContent).toContain(
"Quarterly report",
);
});
expect(container.textContent).not.toContain("# Quarterly report");
expect(mockedLoadArtifactContent).toHaveBeenCalledWith({
filepath,
threadId,
isMock: false,
full: false,
});
});
it("loads the mock artifact source when opened from a mock thread", async () => {
renderViewer({ isMock: true });
await waitFor(() => {
expect(mockedLoadArtifactContent).toHaveBeenCalledWith({
filepath,
threadId,
isMock: true,
full: false,
});
});
});
it("fetches the whole file when a truncated preview is expanded", async () => {
mockedLoadArtifactContent.mockImplementation(async ({ full }) => ({
content: full ? "# Full report\n\nEverything." : "# Full rep",
url: urlOfArtifact({ filepath, threadId }),
sha256: undefined,
truncated: !full,
previewBytes: full ? 27 : 10,
totalBytes: 27,
}));
const { container } = renderViewer();
const loadFull = await screen.findByRole("button", {
name: "Load full file",
});
loadFull.click();
await waitFor(() => {
expect(container.textContent).toContain("Everything.");
});
expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({
filepath,
threadId,
isMock: false,
full: true,
});
expect(screen.queryByRole("button", { name: "Load full file" })).toBe(null);
});
it("offers a download when the artifact cannot be loaded", async () => {
mockedLoadArtifactContent.mockRejectedValue(new Error("boom"));
renderViewer();
const download = await screen.findByRole("link", { name: /download/i });
expect(download.getAttribute("href")).toBe(
urlOfArtifact({ filepath, threadId, download: true }),
);
});
});

View File

@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { fetch as apiFetch } from "@/core/api/fetcher";
describe("api fetcher unauthorized redirect", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = rs.fn(
async () => new Response("", { status: 401 }),
) as unknown as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns the caller to the full URL, query string included", async () => {
window.history.replaceState(
{},
"",
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
// The wrapper redirects and then throws UnauthorizedError; the redirect
// target is what this test is about.
await expect(
apiFetch("/api/threads/t-1/artifacts/mnt/user-data/outputs/report.md"),
).rejects.toThrow();
expect(window.location.href).toContain("/login?next=");
const next = new URL(
window.location.href,
"http://localhost",
).searchParams.get("next");
expect(next).toBe(
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
});
});

View File

@ -0,0 +1,252 @@
import { describe, expect, test } from "@rstest/core";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
ARTIFACT_VIEWER_ROUTE,
artifactViewerTitle,
buildArtifactViewerURL,
requiresAuthenticatedViewer,
parseArtifactViewerParams,
parseArtifactViewerQuery,
resolveArtifactOpenURL,
} from "@/core/artifacts/viewer";
import { validateAuthNextPath } from "@/core/auth/next-path";
import { buildLoginUrl } from "@/core/auth/types";
const threadId = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
function viewerParams(url: string) {
expect(url.startsWith(`${ARTIFACT_VIEWER_ROUTE}?`)).toBe(true);
return new URLSearchParams(url.slice(url.indexOf("?") + 1));
}
describe("resolveArtifactOpenURL", () => {
test("routes markdown artifacts to the standalone viewer", () => {
const filepath = "/mnt/user-data/outputs/report.md";
const params = viewerParams(resolveArtifactOpenURL({ filepath, threadId }));
expect(params.get("path")).toBe(filepath);
expect(params.get("thread_id")).toBe(threadId);
expect(params.get("mock")).toBe(null);
});
test("routes skill archives to the viewer because they render as markdown", () => {
const filepath = "/mnt/user-data/outputs/my-helper.skill";
const params = viewerParams(resolveArtifactOpenURL({ filepath, threadId }));
expect(params.get("path")).toBe(filepath);
});
test("keeps html artifacts on the raw gateway URL so they stay downloads", () => {
const filepath = "/mnt/user-data/outputs/page.html";
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
urlOfArtifact({ filepath, threadId }),
);
});
test("keeps non-markdown text artifacts on the raw gateway URL", () => {
const filepath = "/mnt/user-data/outputs/notes.mdx";
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
urlOfArtifact({ filepath, threadId }),
);
});
test("keeps binary artifacts on the raw gateway URL", () => {
const filepath = "/mnt/user-data/outputs/diagram.png";
expect(resolveArtifactOpenURL({ filepath, threadId })).toBe(
urlOfArtifact({ filepath, threadId }),
);
});
test("carries the mock flag into the viewer URL", () => {
const params = viewerParams(
resolveArtifactOpenURL({
filepath: "/mnt/user-data/outputs/report.md",
threadId,
isMock: true,
}),
);
expect(params.get("mock")).toBe("true");
});
test("carries the mock flag into the raw URL for non-markdown artifacts", () => {
const filepath = "/mnt/user-data/outputs/diagram.png";
expect(resolveArtifactOpenURL({ filepath, threadId, isMock: true })).toBe(
urlOfArtifact({ filepath, threadId, isMock: true }),
);
});
});
describe("parseArtifactViewerParams", () => {
test("round-trips a URL built by resolveArtifactOpenURL", () => {
const filepath = "/mnt/user-data/outputs/sub dir/rapport été.md";
const url = resolveArtifactOpenURL({ filepath, threadId, isMock: true });
expect(parseArtifactViewerParams(viewerParams(url))).toEqual({
filepath,
threadId,
isMock: true,
});
});
test("defaults the mock flag to false when absent", () => {
const params = new URLSearchParams({
path: "/mnt/user-data/outputs/report.md",
thread_id: threadId,
});
expect(parseArtifactViewerParams(params)?.isMock).toBe(false);
});
test("returns null when the path is missing", () => {
const params = new URLSearchParams({ thread_id: threadId });
expect(parseArtifactViewerParams(params)).toBe(null);
});
test("returns null when the thread id is missing", () => {
const params = new URLSearchParams({
path: "/mnt/user-data/outputs/report.md",
});
expect(parseArtifactViewerParams(params)).toBe(null);
});
test("returns null when the path is blank", () => {
const params = new URLSearchParams({ path: " ", thread_id: threadId });
expect(parseArtifactViewerParams(params)).toBe(null);
});
});
describe("parseArtifactViewerQuery", () => {
test("reads the Next.js searchParams record shape", () => {
expect(
parseArtifactViewerQuery({
path: "/mnt/user-data/outputs/report.md",
thread_id: threadId,
mock: "true",
}),
).toEqual({
filepath: "/mnt/user-data/outputs/report.md",
threadId,
isMock: true,
});
});
test("uses the first value when a parameter is repeated", () => {
expect(
parseArtifactViewerQuery({
path: ["/mnt/user-data/outputs/first.md", "/etc/passwd"],
thread_id: threadId,
})?.filepath,
).toBe("/mnt/user-data/outputs/first.md");
});
test("returns null when the record carries no target", () => {
expect(parseArtifactViewerQuery({})).toBe(null);
expect(parseArtifactViewerQuery(undefined)).toBe(null);
});
});
describe("artifactViewerTitle", () => {
test("names the window after the artifact file", () => {
expect(artifactViewerTitle("/mnt/user-data/outputs/report.md")).toBe(
"report.md - DeerFlow",
);
});
test("falls back to the product name without a target", () => {
expect(artifactViewerTitle(undefined)).toBe("DeerFlow");
});
});
describe("buildArtifactViewerURL", () => {
test("addresses the viewer route for any target, markdown or not", () => {
// resolveArtifactOpenURL sends non-markdown to the Gateway; rebuilding the
// window's own address must not follow that branch.
const params = viewerParams(
buildArtifactViewerURL({
filepath: "/mnt/user-data/outputs/diagram.png",
threadId,
isMock: false,
}),
);
expect(params.get("path")).toBe("/mnt/user-data/outputs/diagram.png");
});
});
describe("returning to the viewer after re-authentication", () => {
test("survives the login redirect and resolves back to the same artifact", () => {
const target = {
filepath: "/mnt/user-data/outputs/rapport été.md",
threadId,
isMock: true,
};
const loginUrl = buildLoginUrl(buildArtifactViewerURL(target));
const nextPath = new URLSearchParams(
loginUrl.slice(loginUrl.indexOf("?") + 1),
).get("next");
// The login page drops a `next` it considers unsafe — notably anything
// containing a raw colon — which would strand the window on /workspace.
expect(validateAuthNextPath(nextPath)).toBe(nextPath);
expect(parseArtifactViewerParams(viewerParams(nextPath!))).toEqual(target);
});
});
describe("requiresAuthenticatedViewer", () => {
// A real allowlisted showcase artifact — see STATIC_DEMO_ARTIFACTS.
const demoThreadId = "3823e443-4e2b-4679-b496-a9506eae462b";
const demoFilepath = "/mnt/user-data/outputs/fei-fei-li-podcast-timeline.md";
test("lets a logged-out visitor read a public showcase artifact", () => {
expect(
requiresAuthenticatedViewer({
filepath: demoFilepath,
threadId: demoThreadId,
isMock: true,
}),
).toBe(false);
});
test("gates a mock target the public demo route does not serve", () => {
// `mock=true` is caller-supplied, so the allowlist has to be the authority.
expect(
requiresAuthenticatedViewer({
filepath: "/mnt/user-data/outputs/private-notes.md",
threadId: demoThreadId,
isMock: true,
}),
).toBe(true);
});
test("gates a mock target on a thread that is not a demo thread", () => {
expect(
requiresAuthenticatedViewer({
filepath: demoFilepath,
threadId: "7cfa5f8f-0000-0000-0000-000000000000",
isMock: true,
}),
).toBe(true);
});
test("gates the same artifact when the mock flag is absent", () => {
expect(
requiresAuthenticatedViewer({
filepath: demoFilepath,
threadId: demoThreadId,
isMock: false,
}),
).toBe(true);
});
});

View File

@ -56,7 +56,9 @@ test("loadModels rejects unsuccessful gateway responses", async () => {
});
test("loadModels exposes the typed 401 redirect error", async () => {
const location = { href: "", pathname: "/workspace/chats" };
// `search` is always a string on a real Location; the redirect target is
// built from pathname + search so the stub has to carry both.
const location = { href: "", pathname: "/workspace/chats", search: "" };
rs.stubGlobal("window", { location });
rs.stubGlobal(
"fetch",