- );
+ 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..b109eb9fb4
--- /dev/null
+++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import React, { memo, useMemo } from 'react';
+import { useRouter } from 'next/navigation';
+import DOMPurify from 'dompurify';
+import { FileText, ExternalLink } from 'lucide-react';
+import { cn } from '@/lib/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;
+}
+
+/**
+ * Strips line numbers from content formatted as "123→content"
+ * Used when displaying content that was returned with line numbers for AI context
+ */
+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');
+}
+
+/**
+ * Simple markdown to HTML conversion for basic formatting
+ * Handles: bold, italic, code, links, headers, lists
+ */
+function markdownToHtml(markdown: string): string {
+ let html = markdown
+ // Escape HTML entities first
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ // Headers
+ .replace(/^### (.+)$/gm, '
')
+ .replace(/\n/g, ' ');
+
+ // Wrap in paragraph if not already structured
+ if (!html.startsWith('';
+ }
+
+ // Wrap list items in ul
+ html = html.replace(/(
.*?<\/li>)+/gs, '
$&
');
+
+ return html;
+}
+
+/**
+ * Sanitizes HTML content for safe rendering
+ */
+function sanitizeHtml(html: string): string {
+ if (typeof window === 'undefined') {
+ return html;
+ }
+
+ return DOMPurify.sanitize(html, {
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'style'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur'],
+ KEEP_CONTENT: true,
+ });
+}
+
+/**
+ * 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 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 = useMemo(() => {
+ // Strip line numbers if present
+ const rawContent = stripLineNumbers(content);
+
+ // Convert markdown to HTML if needed
+ const html = isMarkdown ? markdownToHtml(rawContent) : rawContent;
+
+ // Sanitize for security
+ return sanitizeHtml(html);
+ }, [content, isMarkdown]);
+
+ // Check if content looks like HTML
+ const hasHtmlContent = useMemo(() => {
+ return /<[a-z][\s\S]*>/i.test(processedHtml);
+ }, [processedHtml]);
+
+ const handleNavigate = () => {
+ if (pageId) {
+ router.push(`/p/${pageId}`);
+ }
+ };
+
+ return (
+
+ {/* Header - clickable to navigate */}
+
+
+ {/* Content - rendered HTML */}
+
+
+ {!hasHtmlContent && (
+
{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..acb5afd3a6
--- /dev/null
+++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx
@@ -0,0 +1,272 @@
+'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';
+
+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;
+}
+
+/**
+ * Strips line numbers from content formatted as "123→content"
+ */
+function stripLineNumbers(content: string): string {
+ return content
+ .split('\n')
+ .map(line => {
+ const match = line.match(/^\d+→(.*)$/);
+ return match ? match[1] : line;
+ })
+ .join('\n');
+}
+
+/**
+ * Simple diff algorithm using longest common subsequence approach
+ * Returns an array of changes with type (add/remove/unchanged) and value
+ */
+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;
+ 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;
+}
+
+/**
+ * Escapes HTML entities for safe rendering
+ */
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * 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
+ */
+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
+ 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
+ const sanitizedHtml = useMemo(() => {
+ if (typeof window === 'undefined') return diffHtml;
+ return DOMPurify.sanitize(diffHtml, {
+ ALLOWED_TAGS: ['span', 'br', 'p', 'div'],
+ ALLOWED_ATTR: ['class'],
+ });
+ }, [diffHtml]);
+
+ return (
+
')
- .replace(/\n/g, ' ');
-
- // Wrap in paragraph if not already structured
- if (!html.startsWith('';
- }
-
- // Wrap list items in ul
- html = html.replace(/(
.*?<\/li>)+/gs, '
$&
');
-
- return html;
-}
-
-/**
- * Sanitizes HTML content for safe rendering
- */
-function sanitizeHtml(html: string): string {
- if (typeof window === 'undefined') {
- return html;
- }
-
- return DOMPurify.sanitize(html, {
- FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'style'],
- FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur'],
- KEEP_CONTENT: true,
- });
-}
+/** 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
@@ -104,7 +50,7 @@ function sanitizeHtml(html: string): string {
* - Renders HTML/markdown content like a real page preview
* - White background with proper typography
* - Clickable header to navigate to the page
- * - Sanitized for security
+ * - Sanitized using allowlist approach for security
*/
export const RichContentRenderer: React.FC = memo(function RichContentRenderer({
title,
@@ -118,21 +64,21 @@ export const RichContentRenderer: React.FC = memo(func
const router = useRouter();
// Process content: strip line numbers and convert markdown if needed
- const processedHtml = useMemo(() => {
+ 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;
- // Sanitize for security
- return sanitizeHtml(html);
- }, [content, isMarkdown]);
+ // Check if content looks like HTML
+ const isHtml = /<[a-z][\s\S]*>/i.test(html);
- // Check if content looks like HTML
- const hasHtmlContent = useMemo(() => {
- return /<[a-z][\s\S]*>/i.test(processedHtml);
- }, [processedHtml]);
+ // Sanitize HTML content using allowlist approach
+ const sanitized = isHtml ? sanitizeHtmlAllowlist(html) : html;
+
+ return { processedHtml: sanitized, hasHtmlContent: isHtml };
+ }, [content, isMarkdown]);
const handleNavigate = () => {
if (pageId) {
@@ -166,36 +112,23 @@ export const RichContentRenderer: React.FC = memo(func
)}
- {/* Content - rendered HTML */}
+ {/* Content - rendered HTML or plain text */}
-
- {!hasHtmlContent && (
-
{processedHtml}
- )}
-
+ {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
index acb5afd3a6..47e0c0fd26 100644
--- a/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx
+++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx
@@ -5,6 +5,7 @@ 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';
@@ -28,19 +29,6 @@ interface RichDiffRendererProps {
className?: string;
}
-/**
- * Strips line numbers from content formatted as "123→content"
- */
-function stripLineNumbers(content: string): string {
- return content
- .split('\n')
- .map(line => {
- const match = line.match(/^\d+→(.*)$/);
- return match ? match[1] : line;
- })
- .join('\n');
-}
-
/**
* Simple diff algorithm using longest common subsequence approach
* Returns an array of changes with type (add/remove/unchanged) and value
@@ -97,18 +85,6 @@ function computeDiff(oldText: string, newText: string): DiffChange[] {
return changes;
}
-/**
- * Escapes HTML entities for safe rendering
- */
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
/**
* RichDiffRenderer - Shows beautiful visual diffs of content changes
*
@@ -117,6 +93,7 @@ function escapeHtml(text: string): string {
* - 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,
@@ -142,7 +119,7 @@ export const RichDiffRenderer: React.FC = memo(function R
let additions = 0;
let deletions = 0;
- // Build HTML with diff highlighting
+ // Build HTML with diff highlighting using Tailwind classes
const parts = changes.map(change => {
const escapedValue = escapeHtml(change.value);
const htmlValue = escapedValue.replace(/\n/g, ' ');
@@ -150,10 +127,10 @@ export const RichDiffRenderer: React.FC = memo(function R
switch (change.type) {
case 'add':
additions += change.value.length;
- return `${htmlValue}`;
+ return `${htmlValue}`;
case 'remove':
deletions += change.value.length;
- return `${htmlValue}`;
+ return `${htmlValue}`;
default:
return htmlValue;
}
@@ -171,7 +148,7 @@ export const RichDiffRenderer: React.FC = memo(function R
}
};
- // Sanitize the diff HTML
+ // Sanitize the diff HTML using allowlist approach
const sanitizedHtml = useMemo(() => {
if (typeof window === 'undefined') return diffHtml;
return DOMPurify.sanitize(diffHtml, {
@@ -230,38 +207,12 @@ export const RichDiffRenderer: React.FC = memo(function R
{/* Content with diff highlighting */}
-
{
+ // 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, '
')
+ // 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
+ */
+export function sanitizeHtmlAllowlist(html: string): string {
+ if (typeof window === 'undefined') {
+ return html;
+ }
+
+ 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;
From 2c97b1215d0c4d7f77fb5f5c558614c9b247e971 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 1 Feb 2026 18:07:37 +0000
Subject: [PATCH 3/4] fix(ai): prevent unsanitized HTML emission during SSR
- Return empty string instead of raw HTML when window is undefined in sanitizeHtmlAllowlist
- Return empty string in RichDiffRenderer sanitizedHtml useMemo during SSR
- Content will be properly sanitized client-side after hydration
https://claude.ai/code/session_01QemLDWC938Gfr7VSk44yKe
---
.../ai/shared/chat/tool-calls/RichDiffRenderer.tsx | 3 ++-
.../components/ai/shared/chat/tool-calls/content-utils.ts | 7 ++++++-
2 files changed, 8 insertions(+), 2 deletions(-)
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
index 47e0c0fd26..9c19312a64 100644
--- a/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx
+++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx
@@ -149,8 +149,9 @@ export const RichDiffRenderer: React.FC = memo(function R
};
// 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 diffHtml;
+ if (typeof window === 'undefined') return '';
return DOMPurify.sanitize(diffHtml, {
ALLOWED_TAGS: ['span', 'br', 'p', 'div'],
ALLOWED_ATTR: ['class'],
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
index b7229509df..d26490e609 100644
--- 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
@@ -81,10 +81,15 @@ export function markdownToHtml(markdown: string): string {
/**
* 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 html;
+ return '';
}
return DOMPurify.sanitize(html, {
From b04a355056b0ad8005f3d72f9ca126039ece89d9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 1 Feb 2026 18:29:40 +0000
Subject: [PATCH 4/4] fix(ai): prevent form submission and UI freeze in rich
renderers
- Add type="button" to header buttons to prevent accidental form submission
- Add MAX_DIFF_WORDS guard (5000) in computeDiff to prevent UI freeze on large inputs
- Fall back to simple remove/add diff for inputs exceeding the threshold
https://claude.ai/code/session_01QemLDWC938Gfr7VSk44yKe
---
.../chat/tool-calls/RichContentRenderer.tsx | 1 +
.../chat/tool-calls/RichDiffRenderer.tsx | 22 +++++++++++++++++++
2 files changed, 23 insertions(+)
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
index 9e904246d9..14edb295fe 100644
--- a/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx
+++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx
@@ -90,6 +90,7 @@ export const RichContentRenderer: React.FC = memo(func