diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx index fee25bf56e..927127b21f 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx @@ -20,9 +20,9 @@ import { } from 'lucide-react'; import { FileTreeRenderer } from './FileTreeRenderer'; -import { DocumentRenderer } from './DocumentRenderer'; +import { RichContentRenderer } from './RichContentRenderer'; +import { RichDiffRenderer } from './RichDiffRenderer'; import { TaskRenderer } from './TaskRenderer'; -import { getLanguageFromPath } from '@/lib/utils/formatters'; interface TreeItem { path: string; @@ -284,30 +284,50 @@ const CompactToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: stri return
; } - if (toolName === 'read_page' && result.content) { + if (toolName === 'read_page' && (result.rawContent || result.content)) { return (
-
); } - if (toolName === 'replace_lines' && result.content) { - return ( -
- -
- ); + if (toolName === 'replace_lines' && result.success) { + // Show diff if both old and new content available + if (result.oldContent && result.newContent) { + return ( +
+ +
+ ); + } + // Fallback to showing new content + if (result.newContent) { + return ( +
+ +
+ ); + } } return ( diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx new file mode 100644 index 0000000000..14edb295fe --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx @@ -0,0 +1,136 @@ +'use client'; + +import React, { memo, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { FileText, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { stripLineNumbers, markdownToHtml, sanitizeHtmlAllowlist } from './content-utils'; + +interface RichContentRendererProps { + /** Page title for display */ + title: string; + /** HTML or markdown content to render */ + content: string; + /** Page ID for navigation (optional) */ + pageId?: string; + /** Page type for display context */ + pageType?: string; + /** Whether content is markdown (will be rendered as HTML) */ + isMarkdown?: boolean; + /** Maximum height before scrolling (default: 300px) */ + maxHeight?: number; + /** Additional CSS class */ + className?: string; +} + +/** Shared prose styles for rendered content */ +const PROSE_STYLES = cn( + "p-4 text-gray-900 dark:text-gray-100 prose prose-sm max-w-none", + // Typography styles for rendered content + "[&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h1]:mt-0", + "[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4", + "[&_h3]:text-base [&_h3]:font-semibold [&_h3]:mb-2 [&_h3]:mt-3", + "[&_p]:mb-2 [&_p]:leading-relaxed", + "[&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-2", + "[&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-2", + "[&_li]:mb-1", + "[&_code]:bg-gray-100 [&_code]:dark:bg-gray-800 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono", + "[&_pre]:bg-gray-100 [&_pre]:dark:bg-gray-800 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto", + "[&_a]:text-primary [&_a]:underline [&_a]:hover:text-primary/80", + "[&_blockquote]:border-l-4 [&_blockquote]:border-gray-300 [&_blockquote]:dark:border-gray-600 [&_blockquote]:pl-4 [&_blockquote]:italic", + "[&_table]:w-full [&_table]:border-collapse", + "[&_th]:border [&_th]:border-gray-300 [&_th]:dark:border-gray-600 [&_th]:p-2 [&_th]:bg-gray-100 [&_th]:dark:bg-gray-800 [&_th]:font-semibold", + "[&_td]:border [&_td]:border-gray-300 [&_td]:dark:border-gray-600 [&_td]:p-2" +); + +/** + * RichContentRenderer - Renders page content as beautiful HTML + * + * Features: + * - Renders HTML/markdown content like a real page preview + * - White background with proper typography + * - Clickable header to navigate to the page + * - Sanitized using allowlist approach for security + */ +export const RichContentRenderer: React.FC = memo(function RichContentRenderer({ + title, + content, + pageId, + pageType, + isMarkdown = false, + maxHeight = 300, + className +}) { + const router = useRouter(); + + // Process content: strip line numbers and convert markdown if needed + const { processedHtml, hasHtmlContent } = useMemo(() => { + // Strip line numbers if present + const rawContent = stripLineNumbers(content); + + // Convert markdown to HTML if needed + const html = isMarkdown ? markdownToHtml(rawContent) : rawContent; + + // Check if content looks like HTML + const isHtml = /<[a-z][\s\S]*>/i.test(html); + + // Sanitize HTML content using allowlist approach + const sanitized = isHtml ? sanitizeHtmlAllowlist(html) : html; + + return { processedHtml: sanitized, hasHtmlContent: isHtml }; + }, [content, isMarkdown]); + + const handleNavigate = () => { + if (pageId) { + router.push(`/p/${pageId}`); + } + }; + + return ( +
+ {/* Header - clickable to navigate */} + + + {/* Content - rendered HTML or plain text */} +
+ {hasHtmlContent ? ( + // HTML content - render with dangerouslySetInnerHTML (no children) +
+ ) : ( + // Plain text content - render as preformatted text +
+
{processedHtml}
+
+ )} +
+
+ ); +}); diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx new file mode 100644 index 0000000000..38cab26f44 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx @@ -0,0 +1,246 @@ +'use client'; + +import React, { memo, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import DOMPurify from 'dompurify'; +import { FileEdit, ExternalLink, Plus, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { stripLineNumbers, escapeHtml, DIFF_STYLES } from './content-utils'; + +interface DiffChange { + type: 'add' | 'remove' | 'unchanged'; + value: string; +} + +interface RichDiffRendererProps { + /** Page title for display */ + title: string; + /** Original content before changes */ + oldContent: string; + /** New content after changes */ + newContent: string; + /** Page ID for navigation (optional) */ + pageId?: string; + /** Summary of changes (e.g., "3 lines replaced") */ + changeSummary?: string; + /** Maximum height before scrolling (default: 400px) */ + maxHeight?: number; + /** Additional CSS class */ + className?: string; +} + +// Maximum cells for LCS matrix to prevent UI freezing on large inputs +// 10000 words × 10000 words = 100M cells would freeze the browser +const MAX_DIFF_WORDS = 5000; + +/** + * Simple diff algorithm using longest common subsequence approach + * Returns an array of changes with type (add/remove/unchanged) and value + * + * For large inputs exceeding MAX_DIFF_WORDS, falls back to a simple + * remove-old/add-new pair to prevent UI freezing. + */ +function computeDiff(oldText: string, newText: string): DiffChange[] { + const oldWords = oldText.split(/(\s+)/); + const newWords = newText.split(/(\s+)/); + + const changes: DiffChange[] = []; + + // Build LCS table + const m = oldWords.length; + const n = newWords.length; + + // Guard: bail out to cheap fallback for large inputs to prevent UI freeze + // O(m×n) matrix allocation and computation would be too expensive + if (m > MAX_DIFF_WORDS || n > MAX_DIFF_WORDS) { + // Simple fallback: show old as removed, new as added + if (oldText) { + changes.push({ type: 'remove', value: oldText }); + } + if (newText) { + changes.push({ type: 'add', value: newText }); + } + return changes; + } + + const lcs: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldWords[i - 1] === newWords[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + // Backtrack to find diff + let i = m, j = n; + const result: DiffChange[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { + result.unshift({ type: 'unchanged', value: oldWords[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) { + result.unshift({ type: 'add', value: newWords[j - 1] }); + j--; + } else if (i > 0) { + result.unshift({ type: 'remove', value: oldWords[i - 1] }); + i--; + } + } + + // Merge consecutive changes of the same type + for (const change of result) { + const last = changes[changes.length - 1]; + if (last && last.type === change.type) { + last.value += change.value; + } else { + changes.push({ ...change }); + } + } + + return changes; +} + +/** + * RichDiffRenderer - Shows beautiful visual diffs of content changes + * + * Features: + * - Visual diff with green highlights for additions, red for deletions + * - Renders content as rich text, not code + * - Clickable header to navigate to the page + * - Shows change statistics + * - Uses Tailwind dark mode for proper theme support + */ +export const RichDiffRenderer: React.FC = memo(function RichDiffRenderer({ + title, + oldContent, + newContent, + pageId, + changeSummary, + maxHeight = 400, + className +}) { + const router = useRouter(); + + // Process and compute diff + const { diffHtml, stats } = useMemo(() => { + // Strip line numbers if present + const cleanOld = stripLineNumbers(oldContent || ''); + const cleanNew = stripLineNumbers(newContent || ''); + + // Compute diff + const changes = computeDiff(cleanOld, cleanNew); + + // Count additions and deletions + let additions = 0; + let deletions = 0; + + // Build HTML with diff highlighting using Tailwind classes + const parts = changes.map(change => { + const escapedValue = escapeHtml(change.value); + const htmlValue = escapedValue.replace(/\n/g, '
'); + + switch (change.type) { + case 'add': + additions += change.value.length; + return `${htmlValue}`; + case 'remove': + deletions += change.value.length; + return `${htmlValue}`; + default: + return htmlValue; + } + }); + + return { + diffHtml: parts.join(''), + stats: { additions, deletions } + }; + }, [oldContent, newContent]); + + const handleNavigate = () => { + if (pageId) { + router.push(`/p/${pageId}`); + } + }; + + // Sanitize the diff HTML using allowlist approach + // SSR safety: return empty string on server to prevent unsanitized HTML emission + const sanitizedHtml = useMemo(() => { + if (typeof window === 'undefined') return ''; + return DOMPurify.sanitize(diffHtml, { + ALLOWED_TAGS: ['span', 'br', 'p', 'div'], + ALLOWED_ATTR: ['class'], + }); + }, [diffHtml]); + + return ( +
+ {/* Header - clickable to navigate */} + + + {/* Optional change summary */} + {changeSummary && ( +
+ {changeSummary} +
+ )} + + {/* Content with diff highlighting */} +
+
+
+
+ ); +}); diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx index 981ade1168..db940cdbd6 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx @@ -9,7 +9,8 @@ import { } from '@/components/ai/ui/tool'; import { PageAgentConversationRenderer } from '@/components/ai/page-agents'; import { FileTreeRenderer } from './FileTreeRenderer'; -import { DocumentRenderer } from './DocumentRenderer'; +import { RichContentRenderer } from './RichContentRenderer'; +import { RichDiffRenderer } from './RichDiffRenderer'; import { TaskRenderer } from './TaskRenderer'; @@ -43,25 +44,6 @@ const safeJsonParse = (value: unknown): Record | null => { return null; }; -// Helper to infer language from file path -const inferLanguage = (path?: string): string => { - if (!path) return 'plaintext'; - const ext = path.split('.').pop()?.toLowerCase(); - const langMap: Record = { - 'ts': 'typescript', 'tsx': 'typescript', - 'js': 'javascript', 'jsx': 'javascript', - 'py': 'python', 'md': 'markdown', - 'json': 'json', 'css': 'css', 'html': 'html', - 'yml': 'yaml', 'yaml': 'yaml', - 'sh': 'shell', 'bash': 'shell', - 'sql': 'sql', 'xml': 'xml', - 'go': 'go', 'rs': 'rust', - 'java': 'java', 'c': 'c', 'cpp': 'cpp', - 'rb': 'ruby', 'php': 'php' - }; - return langMap[ext || ''] || 'plaintext'; -}; - // Tool name mapping (moved outside component) const TOOL_NAME_MAP: Record = { 'ask_agent': 'Ask Agent', @@ -172,26 +154,43 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = return ; } - if (toolName === 'read_page' && parsedOutput.content) { + // Read page - show rich rendered content + if (toolName === 'read_page' && (parsedOutput.rawContent || parsedOutput.content)) { return ( - ); } - if (toolName === 'replace_lines' && parsedOutput.content) { - return ( - - ); + // Replace lines - show visual diff + if (toolName === 'replace_lines' && parsedOutput.success) { + // If we have both old and new content, show diff + if (parsedOutput.oldContent && parsedOutput.newContent) { + return ( + + ); + } + // Fallback: show the new content if no diff available + if (parsedOutput.newContent) { + return ( + + ); + } } // Generic JSON output for others diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/content-utils.ts b/apps/web/src/components/ai/shared/chat/tool-calls/content-utils.ts new file mode 100644 index 0000000000..d26490e609 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/content-utils.ts @@ -0,0 +1,136 @@ +/** + * Shared utilities for content rendering in tool call displays + */ + +import DOMPurify from 'dompurify'; + +/** + * Strips line numbers from content formatted as "123→content" + * Used when displaying content that was returned with line numbers for AI context + */ +export function stripLineNumbers(content: string): string { + return content + .split('\n') + .map(line => { + // Match pattern: number followed by → then content + const match = line.match(/^\d+→(.*)$/); + return match ? match[1] : line; + }) + .join('\n'); +} + +/** + * Escapes HTML entities for safe rendering + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Simple markdown to HTML conversion for basic formatting + * Handles: bold, italic, code, links, headers, lists + * + * Note: For complex markdown, consider using a full parser. + * This handles common cases in page content. + */ +export function markdownToHtml(markdown: string): string { + let html = markdown + // Escape HTML entities first + .replace(/&/g, '&') + .replace(//g, '>') + // Headers (process before paragraphs) + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold and italic (process in order of specificity) + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Unordered lists + .replace(/^[-*] (.+)$/gm, '
  • $1
  • ') + // Ordered lists + .replace(/^\d+\. (.+)$/gm, '
  • $1
  • ') + // Line breaks (preserve double newlines as paragraphs) + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    '); + + // Wrap in paragraph if not already structured + if (!html.startsWith(''; + } + + // Wrap consecutive list items in ul + html = html.replace(/(

  • .*?<\/li>)+/gs, '
      $&
    '); + + return html; +} + +/** + * Sanitizes HTML content using allowlist approach for security + * More secure than blocklist as it's resilient to new attack vectors + * + * SSR Safety: Returns empty string on server to prevent unsanitized HTML emission. + * Content will be sanitized and rendered client-side after hydration. + */ +export function sanitizeHtmlAllowlist(html: string): string { + // SSR safety: return empty string on server to prevent unsanitized HTML + // The component will re-render client-side with proper sanitization + if (typeof window === 'undefined') { + return ''; + } + + return DOMPurify.sanitize(html, { + // Allowlist approach - only permit known safe tags + ALLOWED_TAGS: [ + // Text formatting + 'p', 'br', 'span', 'div', + 'strong', 'b', 'em', 'i', 'u', 's', 'strike', + 'code', 'pre', 'kbd', 'samp', + // Headings + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + // Lists + 'ul', 'ol', 'li', + // Links + 'a', + // Tables + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', + // Quotes + 'blockquote', 'q', 'cite', + // Other safe elements + 'hr', 'sup', 'sub', 'small', 'mark', + ], + // Only allow safe attributes + ALLOWED_ATTR: [ + 'href', 'title', 'target', 'rel', + 'class', 'id', + 'colspan', 'rowspan', 'scope', + ], + // Additional security + ALLOW_DATA_ATTR: false, + FORBID_CONTENTS: ['script', 'style'], + // Force safe link targets + ADD_ATTR: ['target'], + }); +} + +/** + * CSS classes for diff highlighting that work with Tailwind dark mode + */ +export const DIFF_STYLES = { + add: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200 px-0.5 rounded-sm', + remove: 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 px-0.5 rounded-sm line-through', + unchanged: '', +} as const; diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/index.ts b/apps/web/src/components/ai/shared/chat/tool-calls/index.ts index bce0b425ad..e470ce1734 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/index.ts +++ b/apps/web/src/components/ai/shared/chat/tool-calls/index.ts @@ -10,3 +10,5 @@ export { TaskManagementRenderer } from './TaskManagementRenderer'; export { CompactTaskManagementRenderer } from './CompactTaskManagementRenderer'; export { DocumentRenderer } from './DocumentRenderer'; export { FileTreeRenderer } from './FileTreeRenderer'; +export { RichContentRenderer } from './RichContentRenderer'; +export { RichDiffRenderer } from './RichDiffRenderer'; diff --git a/apps/web/src/lib/ai/tools/page-read-tools.ts b/apps/web/src/lib/ai/tools/page-read-tools.ts index be3c35dc84..2ffb864e61 100644 --- a/apps/web/src/lib/ai/tools/page-read-tools.ts +++ b/apps/web/src/lib/ai/tools/page-read-tools.ts @@ -343,12 +343,17 @@ export const pageReadTools = { const isRangeRequest = lineStart !== undefined || lineEnd !== undefined; + // Raw content for rich rendering (without line numbers) + const rawContent = selectedLines.join('\n'); + return { success: true, + pageId: page.id, title: page.title, type: page.type, isTaskLinked, content: numberedContent, + rawContent, lineCount: selectedLines.length, totalLines, ...(isRangeRequest && { rangeStart: effectiveStart, rangeEnd: effectiveEnd }), diff --git a/apps/web/src/lib/ai/tools/page-write-tools.ts b/apps/web/src/lib/ai/tools/page-write-tools.ts index 39333772f2..3ac892eaba 100644 --- a/apps/web/src/lib/ai/tools/page-write-tools.ts +++ b/apps/web/src/lib/ai/tools/page-write-tools.ts @@ -476,7 +476,11 @@ export const pageWriteTools = { return { success: true, + pageId: page.id, title: page.title, + type: page.type, + oldContent: page.content, + newContent, linesReplaced: endLine - startLine + 1, newLineCount: newLines.length, message: isDeletion