diff --git a/ui/src/components/Artifact/CodeArtifact.tsx b/ui/src/components/Artifact/CodeArtifact.tsx index 376e0d2..97e6e8c 100644 --- a/ui/src/components/Artifact/CodeArtifact.tsx +++ b/ui/src/components/Artifact/CodeArtifact.tsx @@ -1,16 +1,14 @@ /** * CodeArtifact - Syntax-Highlighted Code Output * - * Renders code output from tools like code_interpreter with syntax highlighting. - * Uses a simple pre/code block approach that works with the prose styling. + * Renders code output from tools like code_interpreter with syntax highlighting + * via Shiki. Uses the shared HighlightedCode component. */ -import { memo, useState } from "react"; -import { Copy, Check } from "lucide-react"; +import { memo } from "react"; import type { Artifact, CodeArtifactData } from "@/components/chat-types"; -import { Button } from "@/components/Button/Button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip"; +import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode"; import { cn } from "@/utils/cn"; export interface CodeArtifactProps { @@ -28,64 +26,21 @@ function isCodeArtifactData(data: unknown): data is CodeArtifactData { } function CodeArtifactComponent({ artifact, className }: CodeArtifactProps) { - const [copied, setCopied] = useState(false); - - // Validate and extract data if (!isCodeArtifactData(artifact.data)) { return
Invalid code artifact data
; } const { code, language } = artifact.data; - const handleCopy = async () => { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( -
- {/* Copy button */} -
- - - - - {copied ? "Copied!" : "Copy code"} - -
- - {/* Language badge */} - {language && ( -
- - {language} - -
- )} - - {/* Code block */} - {/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- scrollable region needs keyboard access (axe: scrollable-region-focusable) */} -
-        {code}
-      
- {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} -
+ ); } diff --git a/ui/src/components/Artifact/HtmlArtifact.tsx b/ui/src/components/Artifact/HtmlArtifact.tsx index ee3e4d1..b32820b 100644 --- a/ui/src/components/Artifact/HtmlArtifact.tsx +++ b/ui/src/components/Artifact/HtmlArtifact.tsx @@ -6,11 +6,12 @@ */ import { memo, useState, useRef, useEffect } from "react"; -import { Code2, Eye, Copy, Check, ExternalLink, Maximize2, X } from "lucide-react"; +import { Code2, Eye, ExternalLink, Maximize2, X } from "lucide-react"; import type { Artifact } from "@/components/chat-types"; import { Button } from "@/components/Button/Button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip"; +import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode"; import { cn } from "@/utils/cn"; export interface HtmlArtifactProps { @@ -69,7 +70,6 @@ function wrapHtml(content: string): string { function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) { const [viewMode, setViewMode] = useState<"preview" | "source">("preview"); - const [copied, setCopied] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const iframeRef = useRef(null); @@ -87,12 +87,6 @@ function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) { return
Invalid HTML artifact data
; } - const handleCopy = async () => { - await navigator.clipboard.writeText(html); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - const handleOpenInNewTab = () => { const blob = new Blob([wrapHtml(html)], { type: "text/html" }); const url = URL.createObjectURL(blob); @@ -129,21 +123,6 @@ function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) {
- - - - - {copied ? "Copied!" : "Copy HTML"} - -
diff --git a/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx b/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx new file mode 100644 index 0000000..96ea95c --- /dev/null +++ b/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HighlightedCode } from "./HighlightedCode"; + +const meta = { + title: "Chat/HighlightedCode", + component: HighlightedCode, + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Python: Story = { + args: { + code: `import pandas as pd +import numpy as np + +def analyze(data: list[float]) -> dict: + """Compute summary statistics.""" + arr = np.array(data) + return { + "mean": float(arr.mean()), + "std": float(arr.std()), + "median": float(np.median(arr)), + } + +result = analyze([1, 2, 3, 4, 5]) +print(result)`, + language: "python", + showLanguage: true, + }, +}; + +export const JavaScript: Story = { + args: { + code: `const fetchUsers = async () => { + const response = await fetch('/api/users'); + const data = await response.json(); + return data.users.filter(u => u.active); +}; + +fetchUsers().then(console.log);`, + language: "javascript", + showLanguage: true, + }, +}; + +export const SQL: Story = { + args: { + code: `SELECT u.name, COUNT(o.id) AS order_count +FROM users u +LEFT JOIN orders o ON o.user_id = u.id +WHERE u.created_at > '2024-01-01' +GROUP BY u.name +ORDER BY order_count DESC +LIMIT 10;`, + language: "sql", + showLanguage: true, + }, +}; + +export const Compact: Story = { + args: { + code: `data = [1, 2, 3, 4, 5] +result = sum(data) / len(data) +print(f"Average: {result}")`, + language: "python", + compact: true, + showCopy: false, + }, +}; + +export const WithCopyButton: Story = { + args: { + code: `console.log("Hello, world!");`, + language: "javascript", + showCopy: true, + }, +}; + +export const WithMaxHeight: Story = { + args: { + code: Array(50) + .fill(null) + .map((_, i) => `console.log("Line ${i + 1}");`) + .join("\n"), + language: "javascript", + showLanguage: true, + maxHeight: "200px", + }, +}; + +export const UnknownLanguage: Story = { + args: { + code: "Some plain text content\nwith multiple lines\nand no highlighting", + language: "custom-lang", + }, +}; diff --git a/ui/src/components/HighlightedCode/HighlightedCode.tsx b/ui/src/components/HighlightedCode/HighlightedCode.tsx new file mode 100644 index 0000000..c882565 --- /dev/null +++ b/ui/src/components/HighlightedCode/HighlightedCode.tsx @@ -0,0 +1,169 @@ +import { memo, useState, useEffect, useCallback } from "react"; +import { createHighlighter, type Highlighter } from "shiki"; +import { Copy, Check } from "lucide-react"; + +import { Button } from "@/components/Button/Button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip"; +import { usePreferences } from "@/preferences/PreferencesProvider"; +import { cn } from "@/utils/cn"; + +const THEMES = ["github-light-high-contrast", "github-dark"] as const; +const PRELOADED_LANGS = [ + "python", + "javascript", + "typescript", + "sql", + "json", + "html", + "css", + "bash", +] as const; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [...THEMES], + langs: [...PRELOADED_LANGS], + }); + } + return highlighterPromise; +} + +export interface HighlightedCodeProps { + code: string; + language?: string; + className?: string; + /** Show copy button (default: true) */ + showCopy?: boolean; + /** Show language badge (default: false) */ + showLanguage?: boolean; + /** Max height with overflow scroll */ + maxHeight?: string; + /** Compact mode — smaller font and padding for inline previews */ + compact?: boolean; +} + +function HighlightedCodeComponent({ + code, + language, + className, + showCopy = true, + showLanguage = false, + maxHeight, + compact = false, +}: HighlightedCodeProps) { + const { resolvedTheme } = usePreferences(); + const [html, setHtml] = useState(null); + const [copied, setCopied] = useState(false); + + const theme = resolvedTheme === "dark" ? "github-dark" : "github-light-high-contrast"; + + useEffect(() => { + let cancelled = false; + + getHighlighter().then((highlighter) => { + if (cancelled) return; + + const lang = language?.toLowerCase() ?? "text"; + const loadedLangs = highlighter.getLoadedLanguages(); + + // Use plain text for unknown languages + const effectiveLang = loadedLangs.includes(lang) ? lang : "text"; + + const result = highlighter.codeToHtml(code, { + lang: effectiveLang, + theme, + }); + setHtml(result); + }); + + return () => { + cancelled = true; + }; + }, [code, language, theme]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + } + }, [code]); + + return ( +
+ {showCopy && ( +
+ + + + + {copied ? "Copied!" : "Copy code"} + +
+ )} + + {showLanguage && language && ( +
+ + {language} + +
+ )} + + {/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- scrollable region needs keyboard access (axe: scrollable-region-focusable) */} + {html ? ( +
+ ) : ( +
+          {code}
+        
+ )} + {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} +
+ ); +} + +export const HighlightedCode = memo(HighlightedCodeComponent); diff --git a/ui/src/components/ToolExecution/ToolExecution.stories.tsx b/ui/src/components/ToolExecution/ToolExecution.stories.tsx index eebd49f..1933adc 100644 --- a/ui/src/components/ToolExecution/ToolExecution.stories.tsx +++ b/ui/src/components/ToolExecution/ToolExecution.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { expect, within, userEvent } from "storybook/test"; +import { expect, within, userEvent, waitFor } from "storybook/test"; import type { ToolExecution, ToolExecutionRound, Artifact } from "@/components/chat-types"; import { ToolExecutionBlock } from "./ToolExecutionBlock"; import { ExecutionTimeline } from "./ExecutionTimeline"; @@ -462,9 +462,10 @@ export const StepWithInputExpanded: Story = {
), play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // Code should be visible inline - await expect(canvas.getByText(/import pandas/)).toBeInTheDocument(); + // Code should be visible inline (text may be split across syntax highlighting spans) + await waitFor(() => { + expect(canvasElement.textContent).toMatch(/import pandas/); + }); }, }; @@ -476,8 +477,10 @@ export const StepExpandInput: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Code preview should be visible inline - await expect(canvas.getByText(/import pandas/)).toBeInTheDocument(); + // Code preview should be visible inline (text may be split across syntax highlighting spans) + await waitFor(() => { + expect(canvasElement.textContent).toMatch(/import pandas/); + }); // Click to expand full code const expandButton = canvas.getByText("expand"); await userEvent.click(expandButton); diff --git a/ui/src/components/ToolExecution/ToolExecutionStep.tsx b/ui/src/components/ToolExecution/ToolExecutionStep.tsx index 4a776dd..d8bd0a9 100644 --- a/ui/src/components/ToolExecution/ToolExecutionStep.tsx +++ b/ui/src/components/ToolExecution/ToolExecutionStep.tsx @@ -16,6 +16,7 @@ import { import type { ToolExecution, Artifact, CodeArtifactData } from "@/components/chat-types"; import { cn } from "@/utils/cn"; import { Artifact as ArtifactComponent } from "@/components/Artifact"; +import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode"; import { ArtifactThumbnail } from "./ArtifactThumbnail"; interface ToolExecutionStepProps { @@ -46,6 +47,14 @@ const TOOL_NAMES: Record = { chart_render: "Chart", }; +/** Tool name to language mapping for syntax highlighting */ +const TOOL_LANGUAGES: Record = { + code_interpreter: "python", + js_code_interpreter: "javascript", + sql_query: "sql", + chart_render: "json", +}; + /** Format duration in human-readable form */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; @@ -102,8 +111,10 @@ function ToolExecutionStepComponent({ const code = getCodeFromArtifact(codeArtifact); if (!code) return null; const { preview, isTruncated } = getCodePreview(code, 4); - return { code, preview, isTruncated, artifact: codeArtifact }; - }, [execution.inputArtifacts]); + const language = + (codeArtifact.data as CodeArtifactData)?.language || TOOL_LANGUAGES[execution.toolName]; + return { code, preview, isTruncated, artifact: codeArtifact, language }; + }, [execution.inputArtifacts, execution.toolName]); // Other input artifacts (non-code) const otherInputArtifacts = useMemo( @@ -156,27 +167,48 @@ function ToolExecutionStepComponent({ {/* Inline code preview - always visible */} {inlineCode && (
- +
+ + {/* Code content */} + {inlineCode.isTruncated && ( -
+
+ )} - + )}