fix(frontend): resolve relative message links to artifacts (#7356)

* fix(frontend): resolve relative links in messages to thread artifacts

An assistant message that links a file it wrote with a relative
destination, e.g. [Report](report.md), rendered a plain relative href.
The browser resolved it against the chat route to
/workspace/chats/report.md, so clicking it opened a "thread" whose id is
the file name and the Gateway rejected it (422). Only /mnt/ links were
rewritten, and the clarification and present_files message text did not
get the thread-aware link renderer at all.

- A relative link (no scheme, no leading /, no .. segment) that
  suffix-matches exactly one thread artifact now opens that artifact in
  a new tab, using the rule message images already use. When zero or
  several artifacts match it renders as text instead of a link to a
  broken chat URL. Links outside the message list are unchanged.
- The artifact list reaches the link renderer through a context:
  Streamdown memoizes its root without comparing `components`, so a
  renderer built before the list arrives would keep the stale list.
- /mnt/ artifact links get the normal link styling (they rendered as
  unstyled text before).
- A reopened thread rebuilds its values from the POST /history head,
  which carries messages but not `artifacts`. The message list and the
  artifact panel now add the /mnt/ paths from present_files calls in the
  messages, so links resolve and the panel lists the files after reload.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(frontend): address artifact link review

---------

Co-authored-by: huangzz <110116@nd.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Tsai Yuan 2026-10-23 22:50:52 +08:00 committed by GitHub
parent ef54c14b4d
commit 0530d0c7c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 803 additions and 131 deletions

View File

@ -22,6 +22,10 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
areStringArraysEqual,
collectThreadArtifactPaths,
} from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { useIsMobile } from "@/hooks/use-mobile";
@ -137,10 +141,20 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
const browserViewOpen = browserEnabled && (browserView?.open ?? false);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
// A reopened thread's values carry no `artifacts` (see
// collectThreadArtifactPaths); recover them from its present_files calls.
const stateArtifacts = thread.values.artifacts;
const threadArtifactPaths = useMemo(
() =>
collectThreadArtifactPaths(
Array.isArray(stateArtifacts) ? stateArtifacts : [],
thread.messages,
),
[stateArtifacts, thread.messages],
);
useEffect(() => {
const threadArtifacts = Array.isArray(thread.values.artifacts)
? thread.values.artifacts
: undefined;
const threadArtifacts =
threadArtifactPaths.length > 0 ? [...threadArtifactPaths] : undefined;
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
@ -150,7 +164,7 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
// Update artifacts from the current thread
// An empty initial state must not erase artifacts restored by the provider
// before the persisted thread state has arrived.
if (threadArtifacts && threadArtifacts.length > 0) {
if (threadArtifacts && !areStringArraysEqual(artifacts, threadArtifacts)) {
setArtifacts(threadArtifacts);
}
@ -173,12 +187,13 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
}
}, [
threadId,
artifacts,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
threadArtifactPaths,
]);
const artifactPanelOpen = useMemo(() => {

View File

@ -6,7 +6,7 @@ import {
Loader2Icon,
MessageCircleQuestionMarkIcon,
} from "lucide-react";
import { useId, useState, type KeyboardEvent } from "react";
import { useId, useMemo, useState, type KeyboardEvent } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -37,6 +37,7 @@ import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "./markdown-content";
import { createMarkdownLinkComponent } from "./markdown-link";
export type HumanInputSubmitResult = boolean | void;
@ -219,17 +220,23 @@ export function HumanInputCard({
disabled = false,
pending = false,
answeredResponse = null,
threadId,
onSubmit,
}: {
request: HumanInputRequest;
disabled?: boolean;
pending?: boolean;
answeredResponse?: HumanInputResponse | null;
threadId?: string;
onSubmit?: (
response: HumanInputResponse,
) => HumanInputSubmitResult | Promise<HumanInputSubmitResult>;
}) {
const { t } = useI18n();
const markdownComponents = useMemo(
() => ({ a: createMarkdownLinkComponent(threadId) }),
[threadId],
);
const [text, setText] = useState("");
const [error, setError] = useState("");
const [isComposing, setIsComposing] = useState(false);
@ -379,6 +386,7 @@ export function HumanInputCard({
<MarkdownContent
content={request.context}
isLoading={false}
components={markdownComponents}
/>
</div>
) : null}
@ -405,7 +413,11 @@ export function HumanInputCard({
</div>
<div className="text-foreground text-sm leading-6">
<MarkdownContent content={request.question} isLoading={false} />
<MarkdownContent
content={request.question}
isLoading={false}
components={markdownComponents}
/>
</div>
{isForm ? (

View File

@ -1,6 +1,15 @@
import type { AnchorHTMLAttributes, ComponentProps } from "react";
import {
type AnchorHTMLAttributes,
type ComponentProps,
createContext,
useContext,
} from "react";
import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils";
import {
isPotentialRelativeArtifactReference,
resolveMarkdownArtifactURL,
resolveRelativeArtifactURL,
} from "@/core/artifacts/utils";
import {
retrievalSourceId,
type RetrievalSource,
@ -86,9 +95,24 @@ function isExternalUrl(href: string | undefined): boolean {
return /^https?:\/\//.test(href);
}
/**
* The current thread's artifact paths, provided by the message list. Inside
* it, a link rendered with a `threadId` resolves relative destinations
* (`report.md`, `./out/a.md`) to the artifact they name.
*
* This is a context rather than a factory argument because Streamdown
* memoizes its root without comparing `components`: a link renderer created
* before the artifact list arrives would keep the stale list, while a context
* change still reaches it.
*/
export const MessageArtifactPathsContext = createContext<
readonly string[] | null
>(null);
/**
* Builds the `a` renderer shared by message content and generic markdown.
* Passing a `threadId` also resolves `/mnt/` artifact links; without it those
* Passing a `threadId` also resolves `/mnt/` artifact links, plus relative
* artifact links inside a `MessageArtifactPathsContext`; without it those
* links fall through to the default external-link handling.
*/
export function createMarkdownLinkComponent(
@ -99,6 +123,7 @@ export function createMarkdownLinkComponent(
href,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement>) {
const artifactPaths = useContext(MessageArtifactPathsContext);
if (/^#(?:user-content-)?knowledge-source-/.test(href ?? "")) {
const id = retrievalSourceId(href);
const source = knowledgeSources.find((item) => item.id === id);
@ -142,28 +167,55 @@ export function createMarkdownLinkComponent(
);
}
}
// Artifact links (`/mnt/…`, or relative inside the message list) open in a
// new tab, styled like any other link so they read as clickable.
let resolvedHref = href;
let opensArtifact = false;
if (threadId && href?.startsWith("/mnt/")) {
return (
<a
{...props}
href={resolveMarkdownArtifactURL(href, threadId)}
target="_blank"
rel="noopener noreferrer"
/>
resolvedHref = resolveMarkdownArtifactURL(href, threadId);
opensArtifact = true;
} else if (
threadId &&
artifactPaths &&
href &&
isPotentialRelativeArtifactReference(href)
) {
const artifactURL = resolveRelativeArtifactURL(
href,
threadId,
artifactPaths,
);
if (!artifactURL) {
// Left as-is, the browser would resolve the href against the chat
// route (/workspace/chats/<file>) and open a broken "thread".
const { className, children } = props;
return (
<span
className={cn(
"text-muted-foreground underline decoration-dotted underline-offset-2",
className,
)}
title={`No single artifact in this conversation matches ${href}`}
>
{children}
</span>
);
}
resolvedHref = artifactURL;
opensArtifact = true;
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(href);
const opensNewTab = opensArtifact || isExternalUrl(href);
return (
<a
{...rest}
href={href}
href={resolvedHref}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
target={target ?? (opensNewTab ? "_blank" : undefined)}
rel={rel ?? (opensNewTab ? "noopener noreferrer" : undefined)}
/>
);
};

View File

@ -53,7 +53,11 @@ import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content";
import { isSafeHref, UnsafeLink } from "./markdown-link";
import {
createMarkdownLinkComponent,
isSafeHref,
UnsafeLink,
} from "./markdown-link";
import { MessageReasoning } from "./message-reasoning";
import { ToolCallDetails } from "./tool-call-details";
@ -81,6 +85,10 @@ function MessageGroupComponent({
toolArtifacts,
}: MessageGroupProps) {
const { t } = useI18n();
const markdownComponents = useMemo(
() => ({ a: createMarkdownLinkComponent(threadId) }),
[threadId],
);
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@ -303,7 +311,13 @@ function MessageGroupComponent({
<ChainOfThoughtStep
key={step.id}
icon={MessageSquareTextIcon}
label={<MarkdownContent content={step.content} isLoading={isLoading} />}
label={
<MarkdownContent
content={step.content}
isLoading={isLoading}
components={markdownComponents}
/>
}
></ChainOfThoughtStep>
);
@ -324,6 +338,7 @@ function MessageGroupComponent({
<MarkdownContent
content={step.reasoning ?? ""}
isLoading={isLoading}
components={markdownComponents}
/>
}
></ChainOfThoughtStep>,
@ -479,6 +494,7 @@ function MessageGroupComponent({
<MarkdownContent
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
components={markdownComponents}
/>
}
></ChainOfThoughtStep>
@ -501,7 +517,11 @@ function MessageGroupComponent({
{processingPanel}
{clarificationTextSteps.map((step) => (
<div key={step.id} className="w-full">
<MarkdownContent content={step.content} isLoading={isLoading} />
<MarkdownContent
content={step.content}
isLoading={isLoading}
components={markdownComponents}
/>
</div>
))}
</>

View File

@ -71,7 +71,10 @@ import { Tooltip } from "../tooltip";
import { KnowledgeScopeSummary } from "./knowledge-scope-summary";
import { MarkdownContent } from "./markdown-content";
import { createMarkdownLinkComponent } from "./markdown-link";
import {
createMarkdownLinkComponent,
MessageArtifactPathsContext,
} from "./markdown-link";
import { MessageReasoning } from "./message-reasoning";
function FeedbackButtons({
@ -651,12 +654,14 @@ function MessageContent_({
{reasoningContent}
</MessageReasoning>
)}
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}
className="my-3"
components={components}
/>
<MessageArtifactPathsContext.Provider value={artifactPaths}>
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}
className="my-3"
components={components}
/>
</MessageArtifactPathsContext.Provider>
<CitationSourcesPanel sources={citationSources} />
<KnowledgeSourcesPanel content={contentToDisplay} />
{message.type === "ai" && showWorkspaceChanges && (

View File

@ -26,7 +26,11 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import {
areStringArraysEqual,
collectThreadArtifactPaths,
extractArtifactsFromThread,
} from "@/core/artifacts/utils";
import {
createKnowledgeSourceReader,
type RetrievalSource,
@ -112,6 +116,10 @@ import {
type HumanInputSubmitResult,
} from "./human-input-card";
import { MarkdownContent } from "./markdown-content";
import {
createMarkdownLinkComponent,
MessageArtifactPathsContext,
} from "./markdown-link";
import { MessageGroup, getMessageGroupReasoningMessage } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@ -137,19 +145,12 @@ type SettledStreamMetadataState = {
snapshot: StreamMetadataSnapshot;
};
function sameStrings(previous: readonly string[], next: readonly string[]) {
return (
previous.length === next.length &&
previous.every((value, index) => value === next[index])
);
}
function useStableArtifactPaths(paths: readonly string[] | undefined) {
const previousPathsRef = useRef<readonly string[]>(EMPTY_ARTIFACT_PATHS);
return useMemo(() => {
const nextPaths = paths ?? EMPTY_ARTIFACT_PATHS;
const previousPaths = previousPathsRef.current;
if (sameStrings(previousPaths, nextPaths)) {
if (areStringArraysEqual(previousPaths, nextPaths)) {
return previousPaths;
}
previousPathsRef.current = nextPaths;
@ -1146,8 +1147,16 @@ export function MessageList({
],
);
const artifactPaths = useStableArtifactPaths(
extractArtifactsFromThread(thread),
const stateArtifactPaths = extractArtifactsFromThread(thread);
const threadArtifactPaths = useMemo(
() => collectThreadArtifactPaths(stateArtifactPaths, messages),
[stateArtifactPaths, messages],
);
const artifactPaths = useStableArtifactPaths(threadArtifactPaths);
// Message text rendered outside MessageListItem resolves artifact links too.
const threadLinkComponents = useMemo(
() => ({ a: createMarkdownLinkComponent(threadId) }),
[threadId],
);
if (thread.isThreadLoading && messages.length === 0) {
@ -1418,28 +1427,33 @@ export function MessageList({
group,
groupIndex,
<div className="w-full">
<HumanInputCard
answeredResponse={answeredResponse}
disabled={
thread.isLoading ||
pending ||
Boolean(answeredResponse) ||
humanInputState.latestOpenRequestId !==
humanInputRequest.request_id ||
!onSubmitHumanInput
}
pending={pending}
request={humanInputRequest}
onSubmit={
onSubmitHumanInput
? (response) =>
handleSubmitHumanInput(
humanInputRequest,
response,
)
: undefined
}
/>
<MessageArtifactPathsContext.Provider
value={artifactPaths}
>
<HumanInputCard
answeredResponse={answeredResponse}
disabled={
thread.isLoading ||
pending ||
Boolean(answeredResponse) ||
humanInputState.latestOpenRequestId !==
humanInputRequest.request_id ||
!onSubmitHumanInput
}
pending={pending}
request={humanInputRequest}
threadId={threadId}
onSubmit={
onSubmitHumanInput
? (response) =>
handleSubmitHumanInput(
humanInputRequest,
response,
)
: undefined
}
/>
</MessageArtifactPathsContext.Provider>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
@ -1453,10 +1467,15 @@ export function MessageList({
group,
groupIndex,
<div className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
/>
<MessageArtifactPathsContext.Provider
value={artifactPaths}
>
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
components={threadLinkComponents}
/>
</MessageArtifactPathsContext.Provider>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
@ -1494,11 +1513,16 @@ export function MessageList({
groupIndex,
<div className="w-full">
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
className="mb-4"
/>
<MessageArtifactPathsContext.Provider
value={artifactPaths}
>
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
className="mb-4"
components={threadLinkComponents}
/>
</MessageArtifactPathsContext.Provider>
)}
<ArtifactFileList
archiveDownloadsEnabled={
@ -1547,20 +1571,25 @@ export function MessageList({
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
<MessageArtifactPathsContext.Provider
key={"thinking-group-" + message.id}
messages={[message]}
durationSeconds={
reasoningDurations.get(message)?.durationSeconds
}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
tokenDebugSteps={getTokenDebugStepsForMessages([
message,
])}
showTokenDebugSummaries={showTokenDebugSummaries}
toolArtifacts={thread.values?.tool_artifacts}
/>,
value={artifactPaths}
>
<MessageGroup
messages={[message]}
durationSeconds={
reasoningDurations.get(message)?.durationSeconds
}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
tokenDebugSteps={getTokenDebugStepsForMessages([
message,
])}
showTokenDebugSummaries={showTokenDebugSummaries}
threadId={threadId}
toolArtifacts={thread.values?.tool_artifacts}
/>
</MessageArtifactPathsContext.Provider>,
);
} else if (message.id) {
subagentDebugMessageIds.push(message.id);
@ -1576,15 +1605,22 @@ export function MessageList({
continue;
}
results.push(
<SubtaskCard
<MessageArtifactPathsContext.Provider
key={"task-group-" + taskId}
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
fallbackTask={fallbackTask}
resultMessage={findSubtaskResultMessage(group, taskId)}
/>,
value={artifactPaths}
>
<SubtaskCard
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
fallbackTask={fallbackTask}
resultMessage={findSubtaskResultMessage(
group,
taskId,
)}
/>
</MessageArtifactPathsContext.Provider>,
);
}
}
@ -1606,20 +1642,23 @@ export function MessageList({
group,
groupIndex,
<div className="w-full">
<MessageGroup
messages={group.messages}
durationSeconds={
reasoningDurations.values().next().value?.durationSeconds
}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
threadId={threadId}
tokenDebugSteps={getTokenDebugStepsForMessages(
group.messages,
)}
showTokenDebugSummaries={showTokenDebugSummaries}
toolArtifacts={thread.values?.tool_artifacts}
/>
<MessageArtifactPathsContext.Provider value={artifactPaths}>
<MessageGroup
messages={group.messages}
durationSeconds={
reasoningDurations.values().next().value
?.durationSeconds
}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
threadId={threadId}
tokenDebugSteps={getTokenDebugStepsForMessages(
group.messages,
)}
showTokenDebugSummaries={showTokenDebugSummaries}
toolArtifacts={thread.values?.tool_artifacts}
/>
</MessageArtifactPathsContext.Provider>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,

View File

@ -258,7 +258,11 @@ export function SubtaskCard({
(step.tool_name ?? t.subtasks[task.status])
) : (
<div className="text-muted-foreground line-clamp-3 text-sm">
<MarkdownContent content={step.text} isLoading={false} />
<MarkdownContent
content={step.text}
isLoading={false}
components={reportComponents}
/>
</div>
)
}

View File

@ -1,9 +1,22 @@
import type { Message } from "@langchain/langgraph-sdk";
import { getBackendBaseURL } from "../config";
import { extractPresentFilesFromMessage } from "../messages/utils";
import { isStaticWebsiteOnly } from "../static-mode";
import type { AgentThreadState } from "../threads";
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
export function areStringArraysEqual(
previous: readonly string[],
next: readonly string[],
) {
return (
previous.length === next.length &&
previous.every((value, index) => value === next[index])
);
}
function decodePathSegment(segment: string) {
try {
return decodeURIComponent(segment);
@ -91,6 +104,30 @@ export function extractArtifactsFromThread(thread: {
return thread.values.artifacts ?? EMPTY_ARTIFACT_PATHS;
}
/**
* Adds the `/mnt/` paths passed to `present_files` in `messages` to the
* thread's artifact list. A reopened thread rebuilds its values from the
* Gateway's POST /history head, which carries messages but not `artifacts`;
* the backend fills `artifacts` from those same `present_files` calls, so the
* messages are enough to recover the list. Returns `stateArtifacts` itself
* when no message presents files.
*/
export function collectThreadArtifactPaths(
stateArtifacts: readonly string[],
messages: readonly Message[],
): readonly string[] {
const presented = messages
.flatMap((message) => extractPresentFilesFromMessage(message))
.filter(
(path): path is string =>
typeof path === "string" && path.startsWith("/mnt/"),
);
if (presented.length === 0) {
return stateArtifacts;
}
return [...new Set([...stateArtifacts, ...presented])];
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
if (isStaticWebsiteOnly()) {
return staticDemoArtifactURL({ filepath: absolutePath, threadId });
@ -153,6 +190,73 @@ export function resolveArtifactImageURL(
}
}
function parseRelativeArtifactReference(src: string) {
const { path: relativePath, suffix } = splitPathSuffix(src);
const normalizedPath = relativePath.replace(/^(?:\.\/)+/, "");
const decodedPath = decodeRelativeArtifactPath(normalizedPath);
if (
!normalizedPath ||
normalizedPath.startsWith("/") ||
/^[a-z][a-z\d+.-]*:/i.test(normalizedPath) ||
normalizedPath.startsWith("//") ||
normalizedPath.split("/").includes("..") ||
decodedPath.split("/").includes("..")
) {
return null;
}
return { decodedPath, suffix };
}
/**
* True for a scheme-less relative path, including invalid artifact paths such
* as `../report.md`. Message links in this category must either resolve to a
* known artifact or render inert instead of navigating relative to the chat
* route.
*/
export function isPotentialRelativeArtifactReference(src: string) {
const { path } = splitPathSuffix(src);
return (
Boolean(path) &&
!path.startsWith("/") &&
!path.startsWith("//") &&
!/^[a-z][a-z\d+.-]*:/i.test(path)
);
}
/**
* True for a scheme-less relative reference (`report.md`, `./out/a.png`)
* that could name a thread artifact: no scheme, no leading `/`, no `..`
* segment, and a non-empty path (so `#anchor` and `?query` are excluded).
*/
export function isRelativeArtifactReference(src: string) {
return parseRelativeArtifactReference(src) !== null;
}
/**
* Resolves a relative reference to this thread's artifact URL when it
* suffix-matches exactly one artifact path. Returns `null` when `src` is not
* a relative reference or when zero or several artifacts match.
*/
export function resolveRelativeArtifactURL(
src: string,
threadId: string,
artifactPaths: readonly string[],
) {
const reference = parseRelativeArtifactReference(src);
if (!reference) {
return null;
}
const matches = artifactPaths.filter((path) =>
path.endsWith(`/${reference.decodedPath}`),
);
if (matches.length !== 1) {
return null;
}
return `${resolveArtifactURL(matches[0]!, threadId)}${reference.suffix}`;
}
export function resolveMessageImageURL(
src: string,
threadId: string,
@ -162,27 +266,7 @@ export function resolveMessageImageURL(
return resolveMarkdownArtifactURL(src, threadId);
}
const { path: relativePath, suffix } = splitPathSuffix(src);
const normalizedPath = relativePath.replace(/^(?:\.\/)+/, "");
const decodedNormalizedPath = decodeRelativeArtifactPath(normalizedPath);
if (
!normalizedPath ||
normalizedPath.startsWith("/") ||
/^[a-z][a-z\d+.-]*:/i.test(normalizedPath) ||
normalizedPath.startsWith("//") ||
normalizedPath.split("/").includes("..")
) {
return src;
}
const matches = artifactPaths.filter((path) =>
path.endsWith(`/${decodedNormalizedPath}`),
);
if (matches.length !== 1) {
return src;
}
return `${resolveArtifactURL(matches[0]!, threadId)}${suffix}`;
return resolveRelativeArtifactURL(src, threadId, artifactPaths) ?? src;
}
function staticDemoArtifactURL({

View File

@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
// A relative link in an assistant message (`[Report](final-report.md)`) must
// open the thread artifact it names. Left as a relative href, the browser
// resolves it to /workspace/chats/final-report.md and the chat page then loads
// the file name as a thread id.
const THREAD_ID = "00000000-0000-0000-0000-000000007315";
const REPORT_NAME = "final-report.md";
const REPORT_PATH = `/mnt/user-data/outputs/${REPORT_NAME}`;
const REPORT_API = `/api/threads/${THREAD_ID}/artifacts${REPORT_PATH}`;
test("relative report link in a message opens the thread artifact", async ({
page,
context,
}, testInfo) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Relative artifact link",
artifacts: [REPORT_PATH],
messages: [
{
type: "human",
id: "msg-human-rel-link",
content: [{ type: "text", text: "Write the full report." }],
},
{
type: "ai",
id: "msg-ai-rel-link",
content: `The report is ready: [Final report](${REPORT_NAME}). See also [missing appendix](appendix.md).`,
},
],
},
],
});
const artifactRequests: string[] = [];
const chatNavigations: string[] = [];
await context.route(
(url) => url.pathname === REPORT_API,
(route) => {
artifactRequests.push(route.request().url());
return route.fulfill({
contentType: "text/markdown; charset=utf-8",
body: "# Final report\n\nREPORT_BODY_MARKER",
});
},
);
context.on("request", (request) => {
if (new URL(request.url()).pathname.endsWith(`/chats/${REPORT_NAME}`)) {
chatNavigations.push(request.url());
}
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
const reportLink = page.getByRole("link", { name: "Final report" });
await expect(reportLink).toBeVisible({ timeout: 30_000 });
await expect(reportLink).toHaveAttribute("href", REPORT_API);
await expect(reportLink).toHaveAttribute("target", "_blank");
// A relative link that names no artifact renders as text, not an anchor.
await expect(page.getByText("missing appendix")).toBeVisible();
await expect(
page.getByRole("link", { name: "missing appendix" }),
).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("chat-message.png") });
const popupPromise = context.waitForEvent("page");
await reportLink.click();
const popup = await popupPromise;
await expect(popup.locator("body")).toContainText("REPORT_BODY_MARKER");
expect(new URL(popup.url()).pathname).toBe(REPORT_API);
expect(artifactRequests.length).toBeGreaterThan(0);
expect(chatNavigations).toEqual([]);
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_ID}$`));
});
// The present_files message text is rendered outside MessageListItem.
test("links in the present_files message text resolve to artifacts", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Relative artifact link (present_files)",
artifacts: [REPORT_PATH],
messages: [
{
type: "human",
id: "msg-human-rel-link-present",
content: [{ type: "text", text: "Write the full report." }],
},
{
type: "ai",
id: "msg-ai-rel-link-present",
content: `Done: [relative link](${REPORT_NAME}), [absolute link](${REPORT_PATH}).`,
tool_calls: [
{
id: "present-rel-link",
name: "present_files",
args: { filepaths: [REPORT_PATH] },
},
],
},
],
},
],
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
for (const name of ["relative link", "absolute link"]) {
const link = page.getByRole("link", { name });
await expect(link).toBeVisible({ timeout: 30_000 });
await expect(link).toHaveAttribute("href", REPORT_API);
await expect(link).toHaveAttribute("target", "_blank");
}
});
// A reopened thread rebuilds its values from the Gateway's POST /history head,
// which carries messages but not `artifacts`. Links and the artifact panel
// must still find the files from the present_files call in the messages.
test("reopened thread without state artifacts still resolves links and lists files", async ({
page,
}, testInfo) => {
const files = [
REPORT_PATH,
"/mnt/user-data/outputs/change-notes.md",
"/mnt/user-data/outputs/calculations.zip",
];
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Reopened thread",
// No `artifacts`: mirrors the real /history head.
messages: [
{
type: "human",
id: "msg-human-reopened",
content: [{ type: "text", text: "Deliver the files." }],
},
{
type: "ai",
id: "msg-ai-present-reopened",
content: "",
tool_calls: [
{
id: "present-reopened",
name: "present_files",
args: { filepaths: files },
},
],
},
{
type: "tool",
id: "msg-tool-reopened",
name: "present_files",
tool_call_id: "present-reopened",
content: "Successfully presented files",
},
{
type: "ai",
id: "msg-ai-reopened",
content: [
"All three deliverables are ready.",
"### Downloads",
`- [Final report](./${REPORT_NAME})`,
"- [Change notes](./change-notes.md)",
"- [Calculation package](./calculations.zip)",
].join("\n\n"),
},
],
},
],
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
for (const [name, path] of [
["Final report", files[0]],
["Change notes", files[1]],
["Calculation package", files[2]],
] as const) {
const link = page.getByRole("link", { name, exact: true });
await expect(link).toBeVisible({ timeout: 30_000 });
await expect(link).toHaveAttribute(
"href",
`/api/threads/${THREAD_ID}/artifacts${path}`,
);
}
await page.screenshot({ path: testInfo.outputPath("reopened-message.png") });
// The artifact panel (and its header button) is fed the same way.
await page.getByTestId("artifact-trigger").click();
const panel = page.locator("#artifacts");
for (const file of ["change-notes.md", "calculations.zip"]) {
await expect(panel.getByText(file, { exact: true })).toBeVisible();
}
await page.screenshot({ path: testInfo.outputPath("reopened-panel.png") });
});

View File

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
import { MessageArtifactPathsContext } from "@/components/workspace/messages/markdown-link";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import type { HumanInputRequest } from "@/core/messages/human-input";
@ -35,6 +36,33 @@ afterEach(() => {
});
describe("HumanInputCard form validation (DOM)", () => {
it("resolves relative artifact links in its context and question", () => {
render(
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<MessageArtifactPathsContext.Provider
value={["/mnt/user-data/outputs/report.md"]}
>
<HumanInputCard
request={{
...formRequest,
context: "Review the [draft](report.md).",
question: "Approve the [report](./report.md)?",
}}
threadId="thread-1"
/>
</MessageArtifactPathsContext.Provider>
</I18nContext.Provider>,
);
for (const name of ["draft", "report"]) {
expect(screen.getByRole("link", { name }).getAttribute("href")).toBe(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md",
);
}
});
it("keeps the error node mounted while another field is still invalid", () => {
renderCard();

View File

@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
createMarkdownLinkComponent,
isSafeHref,
MessageArtifactPathsContext,
} from "@/components/workspace/messages/markdown-link";
describe("isSafeHref", () => {
@ -28,6 +29,7 @@ describe("isSafeHref", () => {
it("rejects executable, local, and protocol-relative URLs", () => {
expect(isSafeHref("javascript:alert(1)")).toBe(false);
expect(isSafeHref("java\tscript:alert(1)")).toBe(false);
expect(isSafeHref("data:text/html,<script>alert(1)</script>")).toBe(false);
expect(isSafeHref("file:///etc/passwd")).toBe(false);
expect(isSafeHref("//example.com/path")).toBe(false);
@ -103,3 +105,104 @@ describe("MarkdownLink rendering", () => {
}
});
});
// Inside the message list a relative link must open the thread artifact it
// names. Left as a relative href, the browser resolves it against the chat
// route (/workspace/chats/<file>), which then loads the file name as a thread.
describe("MarkdownLink relative artifact links", () => {
const artifacts = [
"/mnt/user-data/outputs/final report.md",
"/mnt/user-data/outputs/first/shared.md",
"/mnt/user-data/outputs/second/shared.md",
"/mnt/user-data/outputs/notes.md",
];
const MessageLink = createMarkdownLinkComponent("thread-1");
const reportURL =
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/final%20report.md";
const notesURL =
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/notes.md";
function render(href: string, paths: readonly string[] | null = artifacts) {
const link = createElement(MessageLink, { href }, "doc");
return renderToStaticMarkup(
paths === null
? link
: createElement(
MessageArtifactPathsContext.Provider,
{ value: paths },
link,
),
);
}
it("rewrites a uniquely matching relative link to the artifact URL", () => {
// Markdown destinations reach the renderer percent-encoded.
const html = render("final%20report.md");
expect(html).toContain(`href="${reportURL}"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain("text-primary");
expect(render("./final report.md")).toContain(`href="${reportURL}"`);
expect(render("././outputs/notes.md")).toContain(`href="${notesURL}"`);
});
it("keeps ?query and #fragment suffixes on the rewritten URL", () => {
expect(render("notes.md?v=2#part-1")).toContain(
`href="${notesURL}?v=2#part-1"`,
);
});
it("renders ambiguous or unmatched links as text, never a relative href", () => {
for (const href of ["shared.md", "missing.md", "./missing.md"]) {
const html = render(href);
expect(html).not.toContain("<a");
expect(html).not.toContain("href=");
expect(html).toContain("<span");
expect(html).toContain("doc");
}
// The artifact list is empty until the thread state loads.
expect(render("notes.md", [])).not.toContain("href=");
});
it("renders traversal paths inert and preserves non-artifact links", () => {
for (const href of ["../notes.md", "..%2fnotes.md"]) {
const html = render(href);
expect(html).not.toContain("<a");
expect(html).not.toContain("href=");
expect(html).not.toContain("/api/threads/");
}
const external = render("https://example.com/notes.md");
expect(external).toContain('href="https://example.com/notes.md"');
expect(external).toContain('target="_blank"');
expect(render("#notes.md")).toContain('href="#notes.md"');
expect(render("mailto:a@example.com")).toContain(
'href="mailto:a@example.com"',
);
expect(render("/workspace/chats/new")).toContain(
'href="/workspace/chats/new"',
);
});
it("styles /mnt/ artifact links like other links", () => {
const html = render("/mnt/user-data/outputs/chart.png?v=2#detail");
expect(html).toContain(
'href="/api/threads/thread-1/artifacts/mnt/user-data/outputs/chart.png?v=2#detail"',
);
expect(html).toContain('target="_blank"');
expect(html).toContain("text-primary");
});
it("leaves relative links alone outside the message list", () => {
expect(render("missing.md", null)).toContain('href="missing.md"');
const GenericLink = createMarkdownLinkComponent();
expect(
renderToStaticMarkup(
createElement(
MessageArtifactPathsContext.Provider,
{ value: artifacts },
createElement(GenericLink, { href: "notes.md" }, "doc"),
),
),
).toContain('href="notes.md"');
});
});

View File

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { createElement, type ComponentProps } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MessageArtifactPathsContext } from "@/components/workspace/messages/markdown-link";
import {
MessageGroup,
scheduleToolLabel,
@ -37,6 +38,24 @@ afterEach(() => {
});
describe("MessageGroup", () => {
it("resolves relative artifact links in chain-of-thought assistant text", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "Inspect the [report](report.md).",
} as Message,
],
{ isLoading: true, threadId: "thread-1" },
["/mnt/user-data/outputs/report.md"],
);
expect(html).toContain(
'href="/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md"',
);
});
it.each(["array", "webz"])(
"retains valid search links when %s results contain malformed entries",
(shape) => {
@ -879,7 +898,9 @@ function expectRenderedInOrder(html: string, needles: string[]) {
function renderGroup(
messages: Message[],
props: Omit<ComponentProps<typeof MessageGroup>, "messages"> = {},
artifactPaths: readonly string[] | null = null,
) {
const group = createElement(MessageGroup, { ...props, messages });
return renderToStaticMarkup(
createElement(
I18nContext.Provider,
@ -890,7 +911,13 @@ function renderGroup(
t: enUS,
},
},
createElement(MessageGroup, { ...props, messages }),
artifactPaths === null
? group
: createElement(
MessageArtifactPathsContext.Provider,
{ value: artifactPaths },
group,
),
),
);
}

View File

@ -374,6 +374,84 @@ describe("artifact URL helpers", () => {
).toBe("https://example.com/image.png");
});
test("resolveRelativeArtifactURL returns null when it cannot resolve", async () => {
const { isRelativeArtifactReference, resolveRelativeArtifactURL } =
await loadFreshArtifactUtils();
const artifacts = [
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/a/dup.md",
"/mnt/user-data/outputs/b/dup.md",
];
expect(
resolveRelativeArtifactURL("./report.md#s1", "thread-1", artifacts),
).toBe(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md#s1",
);
expect(resolveRelativeArtifactURL("dup.md", "thread-1", artifacts)).toBe(
null,
);
expect(resolveRelativeArtifactURL("none.md", "thread-1", artifacts)).toBe(
null,
);
expect(
resolveRelativeArtifactURL("../report.md", "thread-1", artifacts),
).toBe(null);
expect(isRelativeArtifactReference("report.md")).toBe(true);
expect(isRelativeArtifactReference("./a/b.md?x=1")).toBe(true);
for (const href of [
"../report.md",
"/workspace/chats/x",
"/mnt/user-data/outputs/report.md",
"https://example.com/report.md",
"mailto:a@example.com",
"#section",
"?q=1",
"",
]) {
expect(isRelativeArtifactReference(href)).toBe(false);
}
});
test("collectThreadArtifactPaths adds present_files paths missing from state", async () => {
const { collectThreadArtifactPaths } = await loadFreshArtifactUtils();
const state = ["/mnt/user-data/outputs/a.md"];
const messages = [
{ type: "human", id: "h", content: "go" },
{
type: "ai",
id: "a1",
content: "",
tool_calls: [
{
id: "p1",
name: "present_files",
args: {
filepaths: [
"/mnt/user-data/outputs/a.md",
"/mnt/user-data/outputs/b.zip",
"outputs/relative-is-ignored.md",
],
},
},
],
},
] as Parameters<typeof collectThreadArtifactPaths>[1];
// Reopened thread: the /history head carries no artifacts.
expect(collectThreadArtifactPaths([], messages)).toEqual([
"/mnt/user-data/outputs/a.md",
"/mnt/user-data/outputs/b.zip",
]);
expect(collectThreadArtifactPaths(state, messages)).toEqual([
"/mnt/user-data/outputs/a.md",
"/mnt/user-data/outputs/b.zip",
]);
// Without present_files the state array is returned as-is.
expect(collectThreadArtifactPaths(state, [messages[0]!])).toBe(state);
});
test("builds encoded write-file URLs without undefined query parameters", async () => {
const { buildWriteFileArtifactURL } = await loadFreshArtifactUtils();
const filepath = "/mnt/user-data/outputs/a b#c?%20.md";