diff --git a/app/ui/app/src/components/StreamingMarkdownContent.tsx b/app/ui/app/src/components/StreamingMarkdownContent.tsx index a343140b..5f97a397 100644 --- a/app/ui/app/src/components/StreamingMarkdownContent.tsx +++ b/app/ui/app/src/components/StreamingMarkdownContent.tsx @@ -2,7 +2,8 @@ import React from "react"; import { Streamdown, defaultRemarkPlugins } from "streamdown"; import remarkCitationParser from "@/utils/remarkCitationParser"; import CopyButton from "./CopyButton"; -import { codeToTokens, type BundledLanguage } from "shiki"; +import type { BundledLanguage } from "shiki"; +import { highlighter } from "@/lib/highlighter"; interface StreamingMarkdownContentProps { content: string; @@ -30,9 +31,6 @@ const extractText = (node: React.ReactNode): string => { const CodeBlock = React.memo( ({ children }: React.HTMLAttributes) => { - const [lightTokens, setLightTokens] = React.useState(null); - const [darkTokens, setDarkTokens] = React.useState(null); - // Extract code and language from children const codeElement = children as React.ReactElement<{ className?: string; @@ -42,26 +40,25 @@ const CodeBlock = React.memo( codeElement.props.className?.replace(/language-/, "") || ""; const codeText = extractText(codeElement.props.children); - React.useEffect(() => { - async function highlight() { - try { - const [light, dark] = await Promise.all([ - codeToTokens(codeText, { - lang: language as BundledLanguage, - theme: "github-light", - }), - codeToTokens(codeText, { - lang: language as BundledLanguage, - theme: "github-dark", - }), - ]); - setLightTokens(light); - setDarkTokens(dark); - } catch (error) { - console.error("Failed to highlight code:", error); - } + // Synchronously highlight code using the pre-loaded highlighter + const tokens = React.useMemo(() => { + if (!highlighter) return null; + + try { + return { + light: highlighter.codeToTokensBase(codeText, { + lang: language as BundledLanguage, + theme: "one-light" as any, + }), + dark: highlighter.codeToTokensBase(codeText, { + lang: language as BundledLanguage, + theme: "one-dark" as any, + }), + }; + } catch (error) { + console.error("Failed to highlight code:", error); + return null; } - highlight(); }, [codeText, language]); return ( @@ -81,8 +78,8 @@ const CodeBlock = React.memo( {/* Light mode */}
           
-            {lightTokens
-              ? lightTokens.tokens.map((line: any, i: number) => (
+            {tokens?.light
+              ? tokens.light.map((line: any, i: number) => (
                   
                     {line.map((token: any, j: number) => (
                       
                     ))}
-                    {i < lightTokens.tokens.length - 1 && "\n"}
+                    {i < tokens.light.length - 1 && "\n"}
                   
                 ))
               : codeText}
@@ -103,8 +100,8 @@ const CodeBlock = React.memo(
         {/* Dark mode */}
         
           
-            {darkTokens
-              ? darkTokens.tokens.map((line: any, i: number) => (
+            {tokens?.dark
+              ? tokens.dark.map((line: any, i: number) => (
                   
                     {line.map((token: any, j: number) => (
                       
                     ))}
-                    {i < darkTokens.tokens.length - 1 && "\n"}
+                    {i < tokens.dark.length - 1 && "\n"}
                   
                 ))
               : codeText}
@@ -158,6 +155,26 @@ const StreamingMarkdownContent: React.FC =
           prose-pre:my-0
           prose-pre:max-w-full
           prose-pre:pt-1
+          [&_table]:border-collapse
+          [&_table]:w-full
+          [&_table]:border
+          [&_table]:border-neutral-200
+          [&_table]:rounded-lg
+          [&_table]:overflow-hidden
+          [&_th]:px-3
+          [&_th]:py-2
+          [&_th]:text-left
+          [&_th]:font-semibold
+          [&_th]:border-b
+          [&_th]:border-r
+          [&_th]:border-neutral-200
+          [&_th:last-child]:border-r-0
+          [&_td]:px-3
+          [&_td]:py-2
+          [&_td]:border-r
+          [&_td]:border-neutral-200
+          [&_td:last-child]:border-r-0
+          [&_tbody_tr:not(:last-child)_td]:border-b
           [&_code:not(pre_code)]:text-neutral-700
           [&_code:not(pre_code)]:bg-neutral-100
           [&_code:not(pre_code)]:font-normal
@@ -174,6 +191,10 @@ const StreamingMarkdownContent: React.FC =
           dark:prose-strong:text-neutral-200
           dark:prose-pre:text-neutral-200
           dark:prose:pre:text-neutral-200
+          dark:[&_table]:border-neutral-700
+          dark:[&_thead]:bg-neutral-800
+          dark:[&_th]:border-neutral-700
+          dark:[&_td]:border-neutral-700
           dark:[&_code:not(pre_code)]:text-neutral-200
           dark:[&_code:not(pre_code)]:bg-neutral-800
           dark:[&_code:not(pre_code)]:font-normal
@@ -190,6 +211,7 @@ const StreamingMarkdownContent: React.FC =
             parseIncompleteMarkdown={isStreaming}
             isAnimating={isStreaming}
             remarkPlugins={remarkPlugins}
+            disableTableActions={true}
             components={{
               pre: CodeBlock,
               table: ({
@@ -199,42 +221,12 @@ const StreamingMarkdownContent: React.FC =
                 
{children}
), - thead: ({ - children, - ...props - }: React.HTMLAttributes) => ( - - {children} - - ), - th: ({ - children, - ...props - }: React.HTMLAttributes) => ( - - {children} - - ), - td: ({ - children, - ...props - }: React.HTMLAttributes) => ( - - {children} - - ), // @ts-expect-error: custom citation type "ol-citation": ({ cursor, diff --git a/app/ui/app/src/index.css b/app/ui/app/src/index.css index af1f265d..f1768b3f 100644 --- a/app/ui/app/src/index.css +++ b/app/ui/app/src/index.css @@ -28,3 +28,17 @@ opacity: 1; } } + +/* Hide Streamdown table action buttons */ +.prose button[title="Copy table as markdown"], +.prose button[title="Download table"] { + display: none !important; +} + +/* Hide the parent div if it only contains these buttons */ +.prose + div:has(> button[title="Copy table as markdown"]):has( + > button[title="Download table"] + ) { + display: none !important; +} diff --git a/app/ui/app/src/lib/highlighter.ts b/app/ui/app/src/lib/highlighter.ts new file mode 100644 index 00000000..279bc746 --- /dev/null +++ b/app/ui/app/src/lib/highlighter.ts @@ -0,0 +1,156 @@ +import { createHighlighter } from "shiki"; +import type { ThemeRegistration } from "shiki"; + +const oneLightTheme: ThemeRegistration = { + name: "one-light", + type: "light", + colors: { + "editor.background": "#fafafa", + "editor.foreground": "#383a42", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#a0a1a7" }, + }, + { + scope: ["keyword", "storage.type", "storage.modifier"], + settings: { foreground: "#a626a4" }, + }, + { scope: ["string", "string.quoted"], settings: { foreground: "#50a14f" } }, + { + scope: ["function", "entity.name.function", "support.function"], + settings: { foreground: "#4078f2" }, + }, + { + scope: [ + "constant.numeric", + "constant.language", + "constant.character", + "number", + ], + settings: { foreground: "#c18401" }, + }, + { + scope: ["variable", "support.variable"], + settings: { foreground: "#e45649" }, + }, + { + scope: ["entity.name.tag", "entity.name.type", "entity.name.class"], + settings: { foreground: "#e45649" }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { foreground: "#c18401" }, + }, + { + scope: ["keyword.operator", "operator"], + settings: { foreground: "#a626a4" }, + }, + { scope: ["punctuation"], settings: { foreground: "#383a42" } }, + { + scope: ["markup.heading"], + settings: { foreground: "#e45649", fontStyle: "bold" }, + }, + { + scope: ["markup.bold"], + settings: { foreground: "#c18401", fontStyle: "bold" }, + }, + { + scope: ["markup.italic"], + settings: { foreground: "#a626a4", fontStyle: "italic" }, + }, + ], +}; + +const oneDarkTheme: ThemeRegistration = { + name: "one-dark", + type: "dark", + colors: { + "editor.background": "#282c34", + "editor.foreground": "#abb2bf", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#5c6370" }, + }, + { + scope: ["keyword", "storage.type", "storage.modifier"], + settings: { foreground: "#c678dd" }, + }, + { scope: ["string", "string.quoted"], settings: { foreground: "#98c379" } }, + { + scope: ["function", "entity.name.function", "support.function"], + settings: { foreground: "#61afef" }, + }, + { + scope: [ + "constant.numeric", + "constant.language", + "constant.character", + "number", + ], + settings: { foreground: "#d19a66" }, + }, + { + scope: ["variable", "support.variable"], + settings: { foreground: "#e06c75" }, + }, + { + scope: ["entity.name.tag", "entity.name.type", "entity.name.class"], + settings: { foreground: "#e06c75" }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { foreground: "#d19a66" }, + }, + { + scope: ["keyword.operator", "operator"], + settings: { foreground: "#c678dd" }, + }, + { scope: ["punctuation"], settings: { foreground: "#abb2bf" } }, + { + scope: ["markup.heading"], + settings: { foreground: "#e06c75", fontStyle: "bold" }, + }, + { + scope: ["markup.bold"], + settings: { foreground: "#d19a66", fontStyle: "bold" }, + }, + { + scope: ["markup.italic"], + settings: { foreground: "#c678dd", fontStyle: "italic" }, + }, + ], +}; + +export let highlighter: Awaited> | null = + null; + +export const highlighterPromise = createHighlighter({ + themes: [oneLightTheme, oneDarkTheme], + langs: [ + "javascript", + "typescript", + "python", + "bash", + "shell", + "json", + "html", + "css", + "tsx", + "jsx", + "go", + "rust", + "java", + "c", + "cpp", + "sql", + "yaml", + "markdown", + ], +}).then((h) => { + highlighter = h; + return h; +});