refactor: simplify mention handling and remove unused code
This commit is contained in:
@@ -4,7 +4,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.1] - 2025-12-13
|
||||||
|
|
||||||
- Adjusted model/agent selection alignment
|
- Adjusted model/agent selection alignment
|
||||||
|
- Fixed user message rendering issues
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2025-12-13
|
## [1.1.0] - 2025-12-13
|
||||||
|
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
<FadeInOnReveal>
|
<FadeInOnReveal>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border bg-input/10 dark:bg-input/30 pt-[0.7rem] pb-[0.45rem] relative'
|
'rounded-xl border bg-input/10 dark:bg-input/30 pt-2 pb-2.5 relative'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'color-mix(in srgb, var(--primary-muted, var(--primary)) 40%, var(--interactive-border, transparent))'
|
borderColor: 'color-mix(in srgb, var(--primary-muted, var(--primary)) 40%, var(--interactive-border, transparent))'
|
||||||
|
|||||||
@@ -1,279 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Streamdown } from 'streamdown';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Part } from '@opencode-ai/sdk';
|
import type { Part } from '@opencode-ai/sdk';
|
||||||
import type { AgentMentionInfo } from '../types';
|
import type { AgentMentionInfo } from '../types';
|
||||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
|
||||||
|
|
||||||
const SHIKI_THEMES = ['vitesse-light', 'vitesse-dark'] as const;
|
|
||||||
|
|
||||||
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!codeRef.current) return;
|
|
||||||
const codeEl = codeRef.current.querySelector('code');
|
|
||||||
const code = codeEl?.innerText || '';
|
|
||||||
if (!code) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('group relative', className)} ref={codeRef}>
|
|
||||||
{children}
|
|
||||||
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Copy"
|
|
||||||
>
|
|
||||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table utility functions
|
|
||||||
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
|
||||||
const headers: string[] = [];
|
|
||||||
const rows: string[][] = [];
|
|
||||||
|
|
||||||
const thead = tableEl.querySelector('thead');
|
|
||||||
if (thead) {
|
|
||||||
const headerCells = thead.querySelectorAll('th');
|
|
||||||
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbody = tableEl.querySelector('tbody');
|
|
||||||
if (tbody) {
|
|
||||||
const rowEls = tbody.querySelectorAll('tr');
|
|
||||||
rowEls.forEach(row => {
|
|
||||||
const cells = row.querySelectorAll('td');
|
|
||||||
const rowData: string[] = [];
|
|
||||||
cells.forEach(cell => rowData.push(cell.innerText.trim()));
|
|
||||||
rows.push(rowData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { headers, rows };
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
||||||
const escapeCell = (cell: string): string => {
|
|
||||||
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|
|
||||||
return `"${cell.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (headers.length > 0) {
|
|
||||||
lines.push(headers.map(escapeCell).join(','));
|
|
||||||
}
|
|
||||||
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
|
|
||||||
return lines.join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
||||||
const escapeCell = (cell: string): string => {
|
|
||||||
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (headers.length > 0) {
|
|
||||||
lines.push(headers.map(escapeCell).join('\t'));
|
|
||||||
}
|
|
||||||
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
|
|
||||||
return lines.join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
|
||||||
if (headers.length === 0) return '';
|
|
||||||
|
|
||||||
const escapeCell = (cell: string): string => {
|
|
||||||
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
|
|
||||||
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
|
||||||
rows.forEach(row => {
|
|
||||||
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
|
|
||||||
lines.push(`| ${paddedRow.join(' | ')} |`);
|
|
||||||
});
|
|
||||||
return lines.join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table copy button with dropdown
|
|
||||||
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
const [showMenu, setShowMenu] = React.useState(false);
|
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
||||||
setShowMenu(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = async (format: 'csv' | 'tsv') => {
|
|
||||||
const tableEl = tableRef.current?.querySelector('table');
|
|
||||||
if (!tableEl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = extractTableData(tableEl);
|
|
||||||
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'text/plain': new Blob([content], { type: 'text/plain' }),
|
|
||||||
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setCopied(true);
|
|
||||||
setShowMenu(false);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy table:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
|
||||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Copy table"
|
|
||||||
>
|
|
||||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
{showMenu && (
|
|
||||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
|
||||||
onClick={() => handleCopy('csv')}
|
|
||||||
>
|
|
||||||
CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
|
||||||
onClick={() => handleCopy('tsv')}
|
|
||||||
>
|
|
||||||
TSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table download button with dropdown
|
|
||||||
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
|
||||||
const [showMenu, setShowMenu] = React.useState(false);
|
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
||||||
setShowMenu(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDownload = (format: 'csv' | 'markdown') => {
|
|
||||||
const tableEl = tableRef.current?.querySelector('table');
|
|
||||||
if (!tableEl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = extractTableData(tableEl);
|
|
||||||
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
|
|
||||||
const filename = format === 'csv' ? 'table.csv' : 'table.md';
|
|
||||||
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
|
|
||||||
downloadFile(filename, content, mimeType);
|
|
||||||
setShowMenu(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to download table:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
|
||||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Download table"
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
{showMenu && (
|
|
||||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
|
||||||
onClick={() => handleDownload('csv')}
|
|
||||||
>
|
|
||||||
CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
|
||||||
onClick={() => handleDownload('markdown')}
|
|
||||||
>
|
|
||||||
Markdown
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table wrapper with custom controls
|
|
||||||
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
|
||||||
const tableRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group my-4 flex flex-col space-y-2" data-streamdown="table-wrapper" ref={tableRef}>
|
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<TableCopyButton tableRef={tableRef} />
|
|
||||||
<TableDownloadButton tableRef={tableRef} />
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className={cn('w-full border-collapse border border-border', className)} data-streamdown="table">
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const streamdownComponents = {
|
|
||||||
pre: CodeBlockWrapper,
|
|
||||||
table: TableWrapper,
|
|
||||||
};
|
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||||
|
|
||||||
@@ -284,9 +13,9 @@ type UserTextPartProps = {
|
|||||||
agentMention?: AgentMentionInfo;
|
agentMention?: AgentMentionInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildMentionLink = (token: string, name: string): string => {
|
const buildMentionUrl = (name: string): string => {
|
||||||
const encoded = encodeURIComponent(name);
|
const encoded = encodeURIComponent(name);
|
||||||
return `[${token}](https://opencode.ai/docs/agents/#${encoded})`;
|
return `https://opencode.ai/docs/agents/#${encoded}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile, agentMention }) => {
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile, agentMention }) => {
|
||||||
@@ -298,27 +27,12 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile,
|
|||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||||
const textRef = React.useRef<HTMLDivElement>(null);
|
const textRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const processedText = React.useMemo(() => {
|
|
||||||
if (!agentMention) {
|
|
||||||
return textContent;
|
|
||||||
}
|
|
||||||
const token = agentMention.token;
|
|
||||||
if (!token || token.length === 0) {
|
|
||||||
return textContent;
|
|
||||||
}
|
|
||||||
if (!textContent.includes(token)) {
|
|
||||||
return textContent;
|
|
||||||
}
|
|
||||||
const link = buildMentionLink(token, agentMention.name);
|
|
||||||
return textContent.replace(token, link);
|
|
||||||
}, [agentMention, textContent]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const el = textRef.current;
|
const el = textRef.current;
|
||||||
if (el && !isExpanded) {
|
if (el && !isExpanded) {
|
||||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||||
}
|
}
|
||||||
}, [processedText, isExpanded]);
|
}, [textContent, isExpanded]);
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback(() => {
|
||||||
if (isTruncated || isExpanded) {
|
if (isTruncated || isExpanded) {
|
||||||
@@ -326,14 +40,39 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile,
|
|||||||
}
|
}
|
||||||
}, [isTruncated, isExpanded]);
|
}, [isTruncated, isExpanded]);
|
||||||
|
|
||||||
if (!processedText || processedText.trim().length === 0) {
|
if (!textContent || textContent.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render content with optional agent mention link
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
const idx = textContent.indexOf(agentMention.token);
|
||||||
|
const before = textContent.slice(0, idx);
|
||||||
|
const after = textContent.slice(idx + agentMention.token.length);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<a
|
||||||
|
href={buildMentionUrl(agentMention.name)}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{agentMention.token}
|
||||||
|
</a>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"break-words",
|
"break-words whitespace-pre-wrap",
|
||||||
!isExpanded && "line-clamp-3",
|
!isExpanded && "line-clamp-3",
|
||||||
(isTruncated || isExpanded) && "cursor-pointer"
|
(isTruncated || isExpanded) && "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
@@ -341,15 +80,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile,
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
key={part.id || `${messageId}-user-text`}
|
key={part.id || `${messageId}-user-text`}
|
||||||
>
|
>
|
||||||
<Streamdown
|
{renderContent()}
|
||||||
mode="static"
|
|
||||||
shikiTheme={SHIKI_THEMES}
|
|
||||||
className={cn('streamdown-content streamdown-user', isMobile && 'streamdown-mobile')}
|
|
||||||
controls={{ code: false, table: false }}
|
|
||||||
components={streamdownComponents}
|
|
||||||
>
|
|
||||||
{processedText}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user