toAbsoluteHttpUrl(url) ?? ""}
+ />
+);
diff --git a/src/components/ui/link.test.tsx b/src/components/ui/link.test.tsx
new file mode 100644
index 0000000000..a535b8bdb5
--- /dev/null
+++ b/src/components/ui/link.test.tsx
@@ -0,0 +1,30 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { Link } from "./link";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("", () => {
+ it("sits inside running text without breaking the markup", () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ const { container } = render(
+
+ Read the{" "}
+
+ notes
+
+ .
+
,
+ );
+
+ expect(consoleError).not.toHaveBeenCalled();
+ expect(container.querySelector("a div")).toBeNull();
+ });
+});
diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx
index 69fdce8a70..4f8c0c25a5 100644
--- a/src/components/ui/link.tsx
+++ b/src/components/ui/link.tsx
@@ -53,7 +53,7 @@ function Link({
{...props}
className={cn(linkVariants({ variant, size }), className)}
>
-
+
{children}
{external && (
diff --git a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
index 9a2e983253..377392924f 100644
--- a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
+++ b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
@@ -5,12 +5,13 @@ import {
type ReactNode,
useContext,
} from "react";
-import Markdown, { defaultUrlTransform } from "react-markdown";
-import remarkGfm from "remark-gfm";
+import { type Components, defaultUrlTransform } from "react-markdown";
+import {
+ INLINE_CODE_CLASS,
+ Markdown,
+} from "@/components/shared/Markdown/Markdown";
import { Link } from "@/components/ui/link";
-import { Separator } from "@/components/ui/separator";
-import { Heading, Paragraph } from "@/components/ui/typography";
import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference";
import type { ComponentRefData } from "@/routes/v2/shared/components/AiChat/types";
import { CodeBlock } from "@/routes/v2/shared/components/CodeBlock";
@@ -22,8 +23,6 @@ import { EntityChip } from "./EntityChip";
const ENTITY_PROTOCOL = "entity://";
const COMPONENT_PROTOCOL = "component://";
-const INLINE_CODE_CLASS = "rounded bg-muted px-1 py-0.5 text-xs font-mono";
-
const ComponentRefsContext = createContext<
Record | undefined
>(undefined);
@@ -135,74 +134,13 @@ function MarkdownCode({
);
}
-const markdownComponents = {
- h1: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h2: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h3: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h4: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- p: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- ul: ({ children }: { children?: ReactNode }) => (
-
- ),
- ol: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- li: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- blockquote: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- table: ({ children }: { children?: ReactNode }) => (
-
- ),
- thead: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- tbody: ({ children }: { children?: ReactNode }) => {children},
- tr: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- th: ({ children }: { children?: ReactNode }) => (
- {children} |
- ),
- td: ({ children }: { children?: ReactNode }) => (
- {children} |
- ),
- hr: () => ,
+const chatComponents = {
a: MarkdownLink,
code: MarkdownCode,
+ // `MarkdownCode` already renders a fenced block as a `CodeBlock`, which brings
+ // its own surround.
pre: ({ children }: { children?: ReactNode }) => <>{children}>,
-} as const;
+} satisfies Components;
export function renderMarkdown(
text: string,
@@ -210,12 +148,10 @@ export function renderMarkdown(
): ReactNode {
const markdown = (
- {text}
-
+ />
);
if (!componentReferences) return markdown;
diff --git a/src/utils/URL.test.ts b/src/utils/URL.test.ts
index b62bfbf63f..faf1aa67d1 100644
--- a/src/utils/URL.test.ts
+++ b/src/utils/URL.test.ts
@@ -9,12 +9,60 @@ import {
getIdOrTitleFromPath,
normalizeUrl,
parseHttpUrl,
+ toAbsoluteHttpUrl,
} from "./URL";
vi.mock("@/routes/router", () => ({
RUNS_BASE_PATH: "/runs",
}));
+// Kept ahead of the download tests, which delete `global.URL` in their teardown.
+describe("toAbsoluteHttpUrl", () => {
+ it("accepts absolute http and https urls", () => {
+ expect(toAbsoluteHttpUrl("https://example.com/docs")).toBe(
+ "https://example.com/docs",
+ );
+ expect(toAbsoluteHttpUrl("http://example.com")).toBe("http://example.com/");
+ });
+
+ it("trims surrounding whitespace before parsing", () => {
+ expect(toAbsoluteHttpUrl(" https://example.com/docs ")).toBe(
+ "https://example.com/docs",
+ );
+ });
+
+ it("rejects script-bearing and non-web protocols", () => {
+ expect(toAbsoluteHttpUrl("javascript:alert(1)")).toBeNull();
+ expect(toAbsoluteHttpUrl("JavaScript:alert(1)")).toBeNull();
+ expect(
+ toAbsoluteHttpUrl("data:text/html,"),
+ ).toBeNull();
+ expect(toAbsoluteHttpUrl("vbscript:msgbox(1)")).toBeNull();
+ expect(toAbsoluteHttpUrl("file:///etc/passwd")).toBeNull();
+ });
+
+ it("rejects anything that is not already absolute", () => {
+ expect(toAbsoluteHttpUrl("/runs")).toBeNull();
+ expect(toAbsoluteHttpUrl("runs/123")).toBeNull();
+ expect(toAbsoluteHttpUrl("//evil.example.com")).toBeNull();
+ expect(toAbsoluteHttpUrl("#anchor")).toBeNull();
+ });
+
+ it("rejects empty and whitespace-only input", () => {
+ expect(toAbsoluteHttpUrl("")).toBeNull();
+ expect(toAbsoluteHttpUrl(" ")).toBeNull();
+ });
+
+ it("rejects non-string input, since callers pass unvalidated host data", () => {
+ expect(toAbsoluteHttpUrl(undefined)).toBeNull();
+ expect(toAbsoluteHttpUrl(null)).toBeNull();
+ expect(toAbsoluteHttpUrl(42)).toBeNull();
+ expect(toAbsoluteHttpUrl({ url: "https://example.com" })).toBeNull();
+ expect(toAbsoluteHttpUrl(["https://example.com"])).toBeNull();
+ });
+});
+
+// normalizeUrl tests
describe("normalizeUrl", () => {
it("returns empty string for empty input", () => {
expect(normalizeUrl("")).toBe("");
diff --git a/src/utils/URL.ts b/src/utils/URL.ts
index cb27548a08..8602ab1be4 100644
--- a/src/utils/URL.ts
+++ b/src/utils/URL.ts
@@ -195,6 +195,17 @@ const parseHttpUrl = (value?: string | null): string | undefined => {
}
};
+const toAbsoluteHttpUrl = (value: unknown): string | null => {
+ if (typeof value !== "string" || !value.trim()) return null;
+ try {
+ const url = new URL(value.trim());
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ return url.toString();
+ } catch {
+ return null;
+ }
+};
+
const normalizeUrl = (url: string) => {
if (url.trim() === "") {
return "";
@@ -240,4 +251,5 @@ export {
isGithubUrl,
normalizeUrl,
parseHttpUrl,
+ toAbsoluteHttpUrl,
};