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
{processedHtml}
+ $1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ // Unordered lists
+ .replace(/^[-*] (.+)$/gm, '')
+ .replace(/\n/g, '
');
+
+ // Wrap in paragraph if not already structured
+ if (!html.startsWith('$&
');
+
+ 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