diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index f739b496b0..a57b99e311 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -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 = ({ 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 = ({ // 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 = ({ } }, [ threadId, + artifacts, autoSelectFirstArtifact, deselect, selectArtifact, selectedArtifact, setArtifacts, - thread.values.artifacts, + threadArtifactPaths, ]); const artifactPanelOpen = useMemo(() => { diff --git a/frontend/src/components/workspace/messages/human-input-card.tsx b/frontend/src/components/workspace/messages/human-input-card.tsx index aaad8e2640..f4b00b2630 100644 --- a/frontend/src/components/workspace/messages/human-input-card.tsx +++ b/frontend/src/components/workspace/messages/human-input-card.tsx @@ -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; }) { 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({ ) : null} @@ -405,7 +413,11 @@ export function HumanInputCard({
- +
{isForm ? ( diff --git a/frontend/src/components/workspace/messages/markdown-link.tsx b/frontend/src/components/workspace/messages/markdown-link.tsx index 0073a7ded1..0f5b52245b 100644 --- a/frontend/src/components/workspace/messages/markdown-link.tsx +++ b/frontend/src/components/workspace/messages/markdown-link.tsx @@ -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) { + 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 ( - + 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/) and open a broken "thread". + const { className, children } = props; + return ( + + {children} + + ); + } + resolvedHref = artifactURL; + opensArtifact = true; } const { className, target, rel, ...rest } = props; - const external = isExternalUrl(href); + const opensNewTab = opensArtifact || isExternalUrl(href); return ( ); }; diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 68e2e2f9db..56fb39bdf4 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -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({ } + label={ + + } > ); @@ -324,6 +338,7 @@ function MessageGroupComponent({ } >, @@ -479,6 +494,7 @@ function MessageGroupComponent({ } > @@ -501,7 +517,11 @@ function MessageGroupComponent({ {processingPanel} {clarificationTextSteps.map((step) => (
- +
))} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 0ac5033c2d..f846045cb7 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -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} )} - + + + {message.type === "ai" && showWorkspaceChanges && ( diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 6f71204b57..b1741c4220 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -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(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,
- - handleSubmitHumanInput( - humanInputRequest, - response, - ) - : undefined - } - /> + + + handleSubmitHumanInput( + humanInputRequest, + response, + ) + : undefined + } + /> + {renderTokenUsage({ messages: group.messages, turnUsageMessages, @@ -1453,10 +1467,15 @@ export function MessageList({ group, groupIndex,
- + + + {renderTokenUsage({ messages: group.messages, turnUsageMessages, @@ -1494,11 +1513,16 @@ export function MessageList({ groupIndex,
{group.messages[0] && hasContent(group.messages[0]) && ( - + + + )} , + value={artifactPaths} + > + + , ); } else if (message.id) { subagentDebugMessageIds.push(message.id); @@ -1576,15 +1605,22 @@ export function MessageList({ continue; } results.push( - , + value={artifactPaths} + > + + , ); } } @@ -1606,20 +1642,23 @@ export function MessageList({ group, groupIndex,
- + + + {renderTokenUsage({ messages: group.messages, turnUsageMessages, diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index f61d1b4f7c..0b65b032f4 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -258,7 +258,11 @@ export function SubtaskCard({ (step.tool_name ?? t.subtasks[task.status]) ) : (
- +
) } diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts index adf6d2e82e..977e9e7584 100644 --- a/frontend/src/core/artifacts/utils.ts +++ b/frontend/src/core/artifacts/utils.ts @@ -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({ diff --git a/frontend/tests/e2e/relative-artifact-link.spec.ts b/frontend/tests/e2e/relative-artifact-link.spec.ts new file mode 100644 index 0000000000..0a5a38407e --- /dev/null +++ b/frontend/tests/e2e/relative-artifact-link.spec.ts @@ -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") }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/human-input-card.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/human-input-card.dom.test.tsx index 76bcf006a2..126cbbdfb6 100644 --- a/frontend/tests/unit/components/workspace/messages/human-input-card.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/human-input-card.dom.test.tsx @@ -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( + undefined, t: enUS }} + > + + + + , + ); + + 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(); diff --git a/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts b/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts index c0eb5e7cc8..906413774a 100644 --- a/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts +++ b/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts @@ -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,")).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/), 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(" { + for (const href of ["../notes.md", "..%2fnotes.md"]) { + const html = render(href); + expect(html).not.toContain(" { + 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"'); + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-group.test.ts b/frontend/tests/unit/components/workspace/messages/message-group.test.ts index 4d5b3f5b01..1f541765cf 100644 --- a/frontend/tests/unit/components/workspace/messages/message-group.test.ts +++ b/frontend/tests/unit/components/workspace/messages/message-group.test.ts @@ -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, "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, + ), ), ); } diff --git a/frontend/tests/unit/core/artifacts/utils.test.ts b/frontend/tests/unit/core/artifacts/utils.test.ts index 8955c43cce..9ada69b84e 100644 --- a/frontend/tests/unit/core/artifacts/utils.test.ts +++ b/frontend/tests/unit/core/artifacts/utils.test.ts @@ -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[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";