- Add ComparePane.tsx: side-by-side AI response comparison - Add Memory.tsx: memory management page with CRUD UI - Add McpPermissionDialog.tsx: MCP tool permission approval dialog - Add McpResponseDisplay.tsx: MCP response visualization - Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience - Add EmptyState.tsx: contextual empty state component - Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference - Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard - Add useDraftPersistence.ts: draft message persistence hook - Add useTerminals.ts: terminal session management hook - Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities - Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes - Extend hooks: useTerminalSocket, useSessionStream test suite - Update pages: Home, Project — workspace layout and session flow
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
// v1.14.x-html-artifact-panes: extracted from MessageBubble.tsx so both the
|
|
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
|
|
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
|
|
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
|
|
import { memo, Children, cloneElement, isValidElement } from 'react';
|
|
import type { ReactElement, ReactNode } from 'react';
|
|
import Markdown from 'react-markdown';
|
|
import type { Components } from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { CodeBlock } from './CodeBlock';
|
|
import { MessageBoundary } from './MessageBoundary';
|
|
import { linkifyPaths } from '@/lib/linkify-paths';
|
|
|
|
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
|
const arr = Children.toArray(children);
|
|
return arr.map((child, i) => {
|
|
if (typeof child === 'string') {
|
|
return (
|
|
<span key={`${keyPrefix}-${i}`}>
|
|
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
|
</span>
|
|
);
|
|
}
|
|
if (isValidElement(child)) {
|
|
const el = child as ReactElement<{ children?: ReactNode }>;
|
|
if (el.type === 'code' || el.type === CodeBlock) return child;
|
|
const grandchildren = el.props.children;
|
|
if (grandchildren === undefined) return child;
|
|
return cloneElement(el, {
|
|
key: el.key ?? `linkified-${i}`,
|
|
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
|
});
|
|
}
|
|
return child;
|
|
});
|
|
}
|
|
|
|
const codeRenderer = (props: { children?: unknown; className?: string }) => {
|
|
const { children, className, ...rest } = props;
|
|
const text = String(children ?? '').replace(/\n$/, '');
|
|
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
|
const isBlock = !!langMatch || text.includes('\n');
|
|
if (isBlock) {
|
|
return (
|
|
<MessageBoundary fallback={<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">{text}</pre>}>
|
|
<CodeBlock code={text} lang={langMatch?.[1]} />
|
|
</MessageBoundary>
|
|
);
|
|
}
|
|
return (
|
|
<code
|
|
{...rest}
|
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
|
>
|
|
{children as React.ReactNode}
|
|
</code>
|
|
);
|
|
};
|
|
|
|
// Hoisted to module level — closes over nothing render-scoped, so a stable
|
|
// reference avoids react-markdown reconstructing its vdom on every token delta.
|
|
const MARKDOWN_COMPONENTS: Components = {
|
|
pre: ({ children }) => <>{children}</>,
|
|
code: codeRenderer,
|
|
a: ({ children, href }) => (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
|
),
|
|
ol: ({ children }) => (
|
|
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
|
),
|
|
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
|
p: ({ children }) => (
|
|
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
|
),
|
|
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
|
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
|
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
table: ({ children }) => (
|
|
<div className="overflow-x-auto">
|
|
<table className="border-collapse text-xs">{children}</table>
|
|
</div>
|
|
),
|
|
th: ({ children }) => (
|
|
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="border border-border px-2 py-1">
|
|
{linkifyChildren(children)}
|
|
</td>
|
|
),
|
|
};
|
|
|
|
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
|
return (
|
|
<MessageBoundary>
|
|
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
|
{content}
|
|
</Markdown>
|
|
</MessageBoundary>
|
|
);
|
|
});
|