Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions src/frontend/src/components/core/chatComponents/ContentDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Markdown from "react-markdown";
import rehypeMathjax from "rehype-mathjax/browser";
import remarkGfm from "remark-gfm";
import type { ContentType } from "@/types/chat";
import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils";
import ForwardedIconComponent from "../../common/genericIconComponent";
import SimplifiedCodeTabComponent from "../codeTabsComponent";
import DurationDisplay from "./DurationDisplay";
Expand Down Expand Up @@ -74,7 +75,6 @@ export default function ContentDisplay({
return <>{props.children}</>;
},
code: ({ node, className, children, ...props }) => {
const inline = !(props as any).hasOwnProperty("data-language");
let content = children as string;
if (
Array.isArray(children) &&
Expand All @@ -90,14 +90,16 @@ export default function ContentDisplay({
}
}

const match = /language-(\w+)/.exec(className || "");
if (isCodeBlock(className, props, content)) {
return (
<SimplifiedCodeTabComponent
language={extractLanguage(className)}
code={String(content).replace(/\n$/, "")}
/>
);
}

return !inline ? (
<SimplifiedCodeTabComponent
language={(match && match[1]) || ""}
code={String(content).replace(/\n$/, "")}
/>
) : (
return (
<code className={className} {...props}>
{content}
</code>
Expand Down Expand Up @@ -171,16 +173,16 @@ export default function ContentDisplay({
return <ul className="max-w-full">{props.children}</ul>;
},
code: ({ node, className, children, ...props }) => {
const inline = !(props as any).hasOwnProperty(
"data-language",
);
const match = /language-(\w+)/.exec(className || "");
return !inline ? (
<SimplifiedCodeTabComponent
language={(match && match[1]) || ""}
code={String(children).replace(/\n$/, "")}
/>
) : (
const content = String(children);
if (isCodeBlock(className, props, content)) {
return (
<SimplifiedCodeTabComponent
language={extractLanguage(className)}
code={content.replace(/\n$/, "")}
/>
);
}
return (
<code className={className} {...props}>
{children}
</code>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import { TextShimmer } from "@/components/ui/TextShimmer";
import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils";
import { cn } from "@/utils/utils";
import CodeTabsComponent from "../../../../../../components/core/codeTabsComponent";
import LogoIcon from "./chat-logo-icon";
Expand Down Expand Up @@ -121,9 +122,6 @@ export const ErrorView = ({
children,
...props
}) => {
const inline = !(
props as any
).hasOwnProperty("data-language");
let content = children as string;
if (
Array.isArray(children) &&
Expand All @@ -141,19 +139,23 @@ export const ErrorView = ({
}
}

const match = /language-(\w+)/.exec(
className || "",
);
if (
isCodeBlock(className, props, content)
) {
return (
<CodeTabsComponent
language={extractLanguage(
className,
)}
code={String(content).replace(
/\n$/,
"",
)}
/>
);
}

return !inline ? (
<CodeTabsComponent
language={(match && match[1]) || ""}
code={String(content).replace(
/\n$/,
"",
)}
/>
) : (
return (
<code
className={className}
{...props}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import rehypeMathjax from "rehype-mathjax/browser";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { EMPTY_OUTPUT_SEND_MESSAGE } from "@/constants/constants";
import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils";
import { preprocessChatMessage } from "@/utils/markdownUtils";
import { cn } from "@/utils/utils";
import CodeTabsComponent from "../../../../../../components/core/codeTabsComponent";
Expand Down Expand Up @@ -67,7 +68,6 @@ export const MarkdownField = ({
);
},
code: ({ node, className, children, ...props }) => {
const inline = !(props as any).hasOwnProperty("data-language");
let content = children as string;
if (
Array.isArray(children) &&
Expand All @@ -88,14 +88,16 @@ export const MarkdownField = ({
}
}

const match = /language-(\w+)/.exec(className || "");
if (isCodeBlock(className, props, content)) {
return (
<CodeTabsComponent
language={extractLanguage(className)}
code={String(content).replace(/\n$/, "")}
/>
);
}

return !inline ? (
<CodeTabsComponent
language={(match && match[1]) || ""}
code={String(content).replace(/\n$/, "")}
/>
) : (
return (
<code className={className} {...props}>
{content}
</code>
Expand Down
172 changes: 172 additions & 0 deletions src/frontend/src/utils/__tests__/codeBlockUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { extractLanguage, isCodeBlock } from "../codeBlockUtils";

describe("codeBlockUtils", () => {
describe("isCodeBlock", () => {
describe("should return true (block code)", () => {
it("should_return_true_when_className_has_language_identifier", () => {
const result = isCodeBlock("language-python", {}, "print('hello')");
expect(result).toBe(true);
});

it("should_return_true_when_className_has_language_javascript", () => {
const result = isCodeBlock("language-javascript", {}, "const x = 1");
expect(result).toBe(true);
});

it("should_return_true_when_className_has_language_with_other_classes", () => {
const result = isCodeBlock(
"hljs language-typescript some-other-class",
{},
"const x: number = 1",
);
expect(result).toBe(true);
});

it("should_return_true_when_props_has_data_language", () => {
const result = isCodeBlock(
undefined,
{ "data-language": "python" },
"print('hello')",
);
expect(result).toBe(true);
});

it("should_return_true_when_props_has_data_language_empty_string", () => {
const result = isCodeBlock(undefined, { "data-language": "" }, "code");
expect(result).toBe(true);
});

it("should_return_true_when_content_has_newlines", () => {
const result = isCodeBlock(undefined, {}, "line1\nline2\nline3");
expect(result).toBe(true);
});

it("should_return_true_when_content_has_single_newline", () => {
const result = isCodeBlock(undefined, {}, "line1\nline2");
expect(result).toBe(true);
});

it("should_return_true_when_multiple_conditions_are_met", () => {
const result = isCodeBlock(
"language-python",
{ "data-language": "python" },
"def hello():\n print('world')",
);
expect(result).toBe(true);
});
});

describe("should return false (inline code)", () => {
it("should_return_false_when_no_language_class_no_data_language_no_newlines", () => {
const result = isCodeBlock(undefined, {}, "inline code");
expect(result).toBe(false);
});

it("should_return_false_when_className_is_empty_string", () => {
const result = isCodeBlock("", {}, "simple code");
expect(result).toBe(false);
});

it("should_return_false_when_className_has_no_language_prefix", () => {
const result = isCodeBlock("hljs some-class", {}, "code");
expect(result).toBe(false);
});

it("should_return_false_when_props_is_undefined", () => {
const result = isCodeBlock(undefined, undefined, "inline");
expect(result).toBe(false);
});

it("should_return_false_when_props_is_empty_object", () => {
const result = isCodeBlock(undefined, {}, "x = 1");
expect(result).toBe(false);
});

it("should_return_false_when_content_is_single_line", () => {
const result = isCodeBlock(undefined, {}, "print('hello')");
expect(result).toBe(false);
});

it("should_return_false_when_content_is_empty", () => {
const result = isCodeBlock(undefined, {}, "");
expect(result).toBe(false);
});
});

describe("edge cases", () => {
it("should_handle_language_class_with_numbers", () => {
const result = isCodeBlock("language-es2020", {}, "code");
expect(result).toBe(true);
});

it("should_handle_content_with_carriage_return_only", () => {
// \r alone should not be treated as newline for code block
const result = isCodeBlock(undefined, {}, "line1\rline2");
expect(result).toBe(false);
});

it("should_handle_content_with_crlf", () => {
const result = isCodeBlock(undefined, {}, "line1\r\nline2");
expect(result).toBe(true);
});

it("should_handle_content_with_trailing_newline_only", () => {
const result = isCodeBlock(undefined, {}, "single line\n");
expect(result).toBe(true);
});

it("should_handle_content_with_leading_newline_only", () => {
const result = isCodeBlock(undefined, {}, "\nsingle line");
expect(result).toBe(true);
});

it("should_handle_null_props_gracefully", () => {
// TypeScript would prevent this, but testing runtime safety
const result = isCodeBlock(undefined, null as any, "code");
expect(result).toBe(false);
});
});
});

describe("extractLanguage", () => {
it("should_extract_python_from_language_class", () => {
const result = extractLanguage("language-python");
expect(result).toBe("python");
});

it("should_extract_javascript_from_language_class", () => {
const result = extractLanguage("language-javascript");
expect(result).toBe("javascript");
});

it("should_extract_language_when_mixed_with_other_classes", () => {
const result = extractLanguage("hljs language-typescript highlight");
expect(result).toBe("typescript");
});

it("should_return_empty_string_when_no_language_class", () => {
const result = extractLanguage("hljs some-class");
expect(result).toBe("");
});

it("should_return_empty_string_when_className_is_undefined", () => {
const result = extractLanguage(undefined);
expect(result).toBe("");
});

it("should_return_empty_string_when_className_is_empty", () => {
const result = extractLanguage("");
expect(result).toBe("");
});

it("should_handle_language_with_numbers", () => {
const result = extractLanguage("language-es6");
expect(result).toBe("es6");
});

it("should_extract_first_language_when_multiple_present", () => {
const result = extractLanguage("language-python language-javascript");
expect(result).toBe("python");
});
});
});
30 changes: 30 additions & 0 deletions src/frontend/src/utils/codeBlockUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Determines if a code element should be rendered as a block (with copy button)
* or as inline code.
*
* A code element is considered a block if any of the following conditions are met:
* 1. It has a language class (e.g., "language-python")
* 2. It has the "data-language" attribute (from some markdown parsers)
* 3. The content contains newlines (multi-line code)
*/
export function isCodeBlock(
className: string | undefined,
props: Record<string, unknown> | undefined,
content: string,
): boolean {
const languageMatch = /language-(\w+)/.exec(className ?? "");
const hasLanguageClass = !!languageMatch;
const hasDataLanguage = "data-language" in (props ?? {});
const hasNewlines = content.includes("\n");

return hasLanguageClass || hasDataLanguage || hasNewlines;
}

/**
* Extracts the language identifier from a className.
* Returns empty string if no language is found.
*/
export function extractLanguage(className: string | undefined): string {
const match = /language-(\w+)/.exec(className ?? "");
return match?.[1] ?? "";
}
Loading
Loading