f4d1dfd3a7
feat: context files
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { memo, isValidElement, useState, type ReactNode } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkBreaks from 'remark-breaks';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { IconWand, IconMessageDots, IconFile } from '@tabler/icons-react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Message } from '@/lib/types/http';
|
|
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
|
|
import { InlineCode } from '@/components/task/chat/messages/inline-code';
|
|
import { CodeBlock } from '@/components/task/chat/messages/code-block';
|
|
import { MessageActions } from '@/components/task/chat/messages/message-actions';
|
|
import { useMessageNavigation } from '@/hooks/use-message-navigation';
|
|
|
|
/**
|
|
* Recursively extracts text content from React children.
|
|
* Optimized with fast paths for common cases (string/number).
|
|
*/
|
|
function getTextContent(children: ReactNode): string {
|
|
// Fast path: most common cases first
|
|
if (typeof children === 'string') return children;
|
|
if (typeof children === 'number') return String(children);
|
|
if (children == null) return '';
|
|
|
|
// Use loop instead of map().join() to avoid intermediate array allocation
|
|
if (Array.isArray(children)) {
|
|
let result = '';
|
|
for (let i = 0; i < children.length; i++) {
|
|
result += getTextContent(children[i]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (isValidElement(children)) {
|
|
const props = children.props as { children?: ReactNode };
|
|
if (props.children) {
|
|
return getTextContent(props.children);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
type ChatMessageProps = {
|
|
comment: Message;
|
|
label: string;
|
|
className: string;
|
|
showRichBlocks?: boolean;
|
|
allMessages?: Message[];
|
|
onScrollToMessage?: (messageId: string) => void;
|
|
};
|
|
|
|
// Regex to match @file references (file paths after @)
|
|
// Matches @path/to/file.ext or @file.ext patterns
|
|
const FILE_REF_REGEX = /@([\w./-]+\.[\w]+|[\w/-]+)/g;
|
|
|
|
/**
|
|
* Renders content with file references highlighted in code style
|
|
*/
|
|
function renderContentWithFileRefs(content: string): React.ReactNode[] {
|
|
const parts: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
let keyIndex = 0;
|
|
|
|
FILE_REF_REGEX.lastIndex = 0; // Reset regex state
|
|
while ((match = FILE_REF_REGEX.exec(content)) !== null) {
|
|
// Add text before the match
|
|
if (match.index > lastIndex) {
|
|
parts.push(content.slice(lastIndex, match.index));
|
|
}
|
|
|
|
// Add the file reference with code styling
|
|
const filePath = match[1];
|
|
parts.push(
|
|
<code
|
|
key={`file-ref-${keyIndex++}`}
|
|
className="px-1 py-0.5 bg-emerald-500/20 text-emerald-400 rounded font-mono text-[0.9em]"
|
|
>
|
|
@{filePath}
|
|
</code>
|
|
);
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Add remaining text after last match
|
|
if (lastIndex < content.length) {
|
|
parts.push(content.slice(lastIndex));
|
|
}
|
|
|
|
return parts.length > 0 ? parts : [content];
|
|
}
|
|
|
|
export const ChatMessage = memo(function ChatMessage({ comment, label, className, showRichBlocks, allMessages, onScrollToMessage }: ChatMessageProps) {
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const isUser = comment.author_type === 'user';
|
|
const isTaskDescription = label === 'Task';
|
|
|
|
// Navigation logic for user messages
|
|
const userNavigation = useMessageNavigation(
|
|
allMessages || [],
|
|
comment.id,
|
|
'user'
|
|
);
|
|
|
|
const handleNavigatePrev = () => {
|
|
if (userNavigation.previous && onScrollToMessage) {
|
|
onScrollToMessage(userNavigation.previous.id);
|
|
}
|
|
};
|
|
|
|
const handleNavigateNext = () => {
|
|
if (userNavigation.next && onScrollToMessage) {
|
|
onScrollToMessage(userNavigation.next.id);
|
|
}
|
|
};
|
|
|
|
// Keep the old card-based layout for task descriptions (amber banner)
|
|
if (isTaskDescription) {
|
|
return (
|
|
<div className={cn('w-full rounded-lg px-4 py-3 text-xs', className)}>
|
|
<div className="flex items-center">
|
|
<p className="text-[11px] uppercase tracking-wide opacity-70">
|
|
{comment.requests_input ? (
|
|
<span className="ml-2 rounded-full bg-amber-500/20 px-2 py-0.5 text-[10px] text-amber-300">
|
|
Needs input
|
|
</span>
|
|
) : null}
|
|
</p>
|
|
</div>
|
|
<p className="whitespace-pre-wrap">
|
|
{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// User message: right-aligned bubble with markdown support
|
|
if (isUser) {
|
|
// Extract image attachments, context files, and context flags from metadata
|
|
const metadata = comment.metadata as {
|
|
attachments?: Array<{ type: string; data: string; mime_type: string }>;
|
|
plan_mode?: boolean;
|
|
has_review_comments?: boolean;
|
|
context_files?: Array<{ path: string; name: string }>;
|
|
} | undefined;
|
|
const imageAttachments = (metadata?.attachments || []).filter(att => att.type === 'image');
|
|
const contextFiles = metadata?.context_files || [];
|
|
const hasPlanMode = !!metadata?.plan_mode;
|
|
const hasReviewComments = !!metadata?.has_review_comments;
|
|
const hasContent = comment.content && comment.content.trim() !== '';
|
|
const hasAttachments = imageAttachments.length > 0;
|
|
|
|
return (
|
|
<div className="flex justify-end w-full overflow-hidden">
|
|
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden group">
|
|
{/* Context badges */}
|
|
{(hasPlanMode || hasReviewComments || contextFiles.length > 0) && (
|
|
<div className="flex justify-end gap-1.5 mb-1 flex-wrap">
|
|
{hasPlanMode && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] text-purple-400">
|
|
<IconWand size={10} /> Plan mode
|
|
</span>
|
|
)}
|
|
{hasReviewComments && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/20 px-2 py-0.5 text-[10px] text-blue-400">
|
|
<IconMessageDots size={10} /> Review comments
|
|
</span>
|
|
)}
|
|
{contextFiles.map((f) => (
|
|
<span key={f.path} className="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
<IconFile size={10} /> {f.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 text-xs overflow-hidden">
|
|
{/* Display image attachments */}
|
|
{hasAttachments && (
|
|
<div className={cn('flex flex-wrap gap-2', hasContent && 'mb-2')}>
|
|
{imageAttachments.map((att, index) => (
|
|
/* eslint-disable-next-line @next/next/no-img-element -- base64 data URLs are not compatible with next/image */
|
|
<img
|
|
key={index}
|
|
src={`data:${att.mime_type};base64,${att.data}`}
|
|
alt={`Attachment ${index + 1}`}
|
|
className="max-h-48 max-w-full rounded-lg object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
|
onClick={() => {
|
|
// Open image in new tab for full view
|
|
const win = window.open();
|
|
if (win) {
|
|
win.document.write(`<img src="data:${att.mime_type};base64,${att.data}" style="max-width:100%;height:auto;" />`);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Display text content */}
|
|
{hasContent ? (
|
|
showRaw ? (
|
|
<pre className="whitespace-pre-wrap font-mono text-xs">
|
|
{comment.content}
|
|
</pre>
|
|
) : (
|
|
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
|
{renderContentWithFileRefs(comment.content)}
|
|
</p>
|
|
)
|
|
) : !hasAttachments ? (
|
|
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">(empty)</p>
|
|
) : null}
|
|
</div>
|
|
<MessageActions
|
|
message={comment}
|
|
showCopy={true}
|
|
showTimestamp={true}
|
|
showRawToggle={true}
|
|
showNavigation={!!allMessages && allMessages.length > 0}
|
|
isRawView={showRaw}
|
|
onToggleRaw={() => setShowRaw(!showRaw)}
|
|
onNavigatePrev={handleNavigatePrev}
|
|
onNavigateNext={handleNavigateNext}
|
|
hasPrev={userNavigation.hasPrevious}
|
|
hasNext={userNavigation.hasNext}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Agent message: icon on left, no card background
|
|
return (
|
|
<div className="flex items-start gap-2 sm:gap-3 w-full group">
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0 text-xs">
|
|
{showRaw ? (
|
|
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
|
|
{comment.content || '(empty)'}
|
|
</pre>
|
|
) : (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-4 prose-p:leading-relaxed prose-ul:my-4 prose-ul:list-disc prose-ul:pl-6 prose-ol:my-4 prose-ol:list-decimal prose-ol:pl-6 prose-li:my-1.5 prose-pre:my-5 prose-strong:text-foreground prose-strong:font-bold prose-headings:text-foreground prose-headings:font-bold">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
|
components={{
|
|
code: ({ className, children }) => {
|
|
const content = getTextContent(children).replace(/\n$/, '');
|
|
const hasLanguage = className?.startsWith('language-');
|
|
const hasNewlines = content.includes('\n');
|
|
|
|
// Code block if it has a language specifier OR has multiple lines
|
|
if (hasLanguage || hasNewlines) {
|
|
return <CodeBlock className={className}>{content}</CodeBlock>;
|
|
}
|
|
|
|
// Inline code (single backticks, single line)
|
|
return <InlineCode>{content}</InlineCode>;
|
|
},
|
|
ol: ({ children }) => (
|
|
<ol className="list-decimal pl-6 mb-2">
|
|
{children}
|
|
</ol>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className="list-disc pl-6 mb-2">
|
|
{children}
|
|
</ul>
|
|
),
|
|
li: ({ children }) => (
|
|
<li className="my-0.5">
|
|
{children}
|
|
</li>
|
|
),
|
|
p: ({ children }) => (
|
|
<p className="leading-relaxed mb-1.5">
|
|
{children}
|
|
</p>
|
|
),
|
|
h1: ({ children }) => (
|
|
<p className="my-3 font-bold text-sm">
|
|
{children}
|
|
</p>
|
|
),
|
|
h2: ({ children }) => (
|
|
<p className="my-2 font-bold text-sm">
|
|
{children}
|
|
</p>
|
|
),
|
|
h3: ({ children }) => (
|
|
<p className="my-2 font-bold text-sm">
|
|
{children}
|
|
</p>
|
|
),
|
|
h4: ({ children }) => (
|
|
<p className="my-2 font-bold">
|
|
{children}
|
|
</p>
|
|
),
|
|
h5: ({ children }) => (
|
|
<p className="my-2 font-bold">
|
|
{children}
|
|
</p>
|
|
),
|
|
hr: ({ children }) => (
|
|
<hr className="my-5">
|
|
{children}
|
|
</hr>
|
|
),
|
|
table: ({ children }) => (
|
|
<div className="my-3 overflow-x-auto">
|
|
<table className="border-collapse border border-border rounded-lg overflow-hidden">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
thead: ({ children }) => (
|
|
<thead className="bg-muted/50">
|
|
{children}
|
|
</thead>
|
|
),
|
|
tbody: ({ children }) => (
|
|
<tbody className="divide-y divide-border">
|
|
{children}
|
|
</tbody>
|
|
),
|
|
tr: ({ children }) => (
|
|
<tr className="border-b border-border last:border-b-0 hover:bg-muted/50">
|
|
{children}
|
|
</tr>
|
|
),
|
|
th: ({ children }) => (
|
|
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground border-r border-border last:border-r-0">
|
|
{children}
|
|
</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="px-3 py-2 text-xs text-muted-foreground border-r border-border last:border-r-0">
|
|
{children}
|
|
</td>
|
|
),
|
|
}}
|
|
>
|
|
{comment.content || '(empty)'}
|
|
</ReactMarkdown>
|
|
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
|
|
</div>
|
|
)}
|
|
<MessageActions
|
|
message={comment}
|
|
showCopy={true}
|
|
showTimestamp={true}
|
|
showRawToggle={true}
|
|
showModel={true}
|
|
showNavigation={false}
|
|
isRawView={showRaw}
|
|
onToggleRaw={() => setShowRaw(!showRaw)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|