mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 18:43:08 +08:00
Chat & sidebar UX improvements (#929)
This commit is contained in:
@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
||||
// everything else is treated as a workspace file reference.
|
||||
const isExternalHref = (href?: string): boolean =>
|
||||
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
||||
|
||||
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
||||
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
||||
|
||||
// A usable file path contains a separator or a filename with an extension.
|
||||
const looksLikeFilePath = (value?: string): value is string => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const cleaned = stripLineSuffix(value.trim());
|
||||
if (!cleaned || cleaned === '#') {
|
||||
return false;
|
||||
}
|
||||
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
||||
};
|
||||
|
||||
// Extract plain text from link children so a reference rendered only as link
|
||||
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
||||
const childrenToText = (children: React.ReactNode): string => {
|
||||
if (typeof children === 'string' || typeof children === 'number') {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(childrenToText).join('');
|
||||
}
|
||||
if (React.isValidElement(children)) {
|
||||
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
type CodeBlockProps = {
|
||||
node?: any;
|
||||
inline?: boolean;
|
||||
@@ -123,11 +159,6 @@ const markdownComponents = {
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
const { openFileInEditor } = usePaletteOps();
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
...markdownComponents,
|
||||
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
||||
// Prefer the href when it is a real path; otherwise fall back to the
|
||||
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
||||
const linkText = childrenToText(linkChildren);
|
||||
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
||||
|
||||
if (fileRef && !isExternalHref(href)) {
|
||||
return (
|
||||
<a
|
||||
href={href || fileRef}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openFileInEditor(stripLineSuffix(fileRef));
|
||||
}}
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[openFileInEditor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user