mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: add markdown composer backend foundation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
import { reasoningEffortOptions, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session';
|
||||
import { hasMeaningfulChatMessageContent, prepareChatMessageContent } from '@shared/utils/chatMessage';
|
||||
import {
|
||||
listApprovalToolDefinitions,
|
||||
type ApprovalToolDefinition,
|
||||
@@ -694,6 +695,7 @@ export function ChatPane({
|
||||
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
||||
const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
||||
const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig;
|
||||
const canSubmitInput = hasMeaningfulChatMessageContent(input) && !isComposerDisabled;
|
||||
|
||||
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
const mcpServers = toolingSettings.mcpServers;
|
||||
@@ -729,7 +731,7 @@ export function ChatPane({
|
||||
}, [session.id]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const text = input.trim();
|
||||
const text = prepareChatMessageContent(input);
|
||||
if (!text || isComposerDisabled) return;
|
||||
setInput('');
|
||||
await onSend(text);
|
||||
@@ -1042,11 +1044,11 @@ export function ChatPane({
|
||||
/>
|
||||
<button
|
||||
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
||||
input.trim() && !isComposerDisabled
|
||||
canSubmitInput
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
}`}
|
||||
disabled={isComposerDisabled || !input.trim()}
|
||||
disabled={!canSubmitInput}
|
||||
onClick={() => void handleSubmit()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -1,113 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Check, Clipboard } from 'lucide-react';
|
||||
|
||||
function extractText(node: unknown): string {
|
||||
if (!node || typeof node !== 'object') return '';
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n.type === 'text') return String(n.value ?? '');
|
||||
if (Array.isArray(n.children)) {
|
||||
return (n.children as unknown[]).map(extractText).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function CodeBlock({ language, children }: { language: string; children: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
void navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative my-3 overflow-hidden rounded-lg border border-zinc-800 bg-[#0d0d10]">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800/80 px-4 py-1.5">
|
||||
<span className="select-none text-[11px] text-zinc-500">
|
||||
{language || 'text'}
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[11px] text-zinc-500 transition hover:text-zinc-300"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-3" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard className="size-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4">
|
||||
<code className="font-mono text-[13px] leading-relaxed text-zinc-300">
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markdownComponents: Record<string, (props: Record<string, unknown>) => ReactNode> = {
|
||||
pre({ children, node }: Record<string, unknown>) {
|
||||
const codeChild = (node as Record<string, unknown[]>)?.children?.[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (codeChild && (codeChild as Record<string, string>).tagName === 'code') {
|
||||
const classNames = ((codeChild.properties as Record<string, string[]>)?.className ??
|
||||
[]) as string[];
|
||||
const hasLanguage = classNames.some((c) => c.startsWith('language-'));
|
||||
|
||||
if (!hasLanguage) {
|
||||
const text = extractText(codeChild).replace(/\n$/, '');
|
||||
return <CodeBlock language="">{text}</CodeBlock>;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children as ReactNode}</>;
|
||||
},
|
||||
|
||||
code({ className, children, ...rest }: Record<string, unknown>) {
|
||||
const match = /language-(\w+)/.exec(String(className ?? ''));
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={match[1]}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-[0.875em] text-zinc-200"
|
||||
{...rest}
|
||||
>
|
||||
{children as ReactNode}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
a({ href, children }: Record<string, unknown>) {
|
||||
return (
|
||||
<a
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
href={String(href)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children as ReactNode}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
import { chatMarkdownComponents, chatMarkdownRemarkPlugins } from '@renderer/lib/chatMarkdown';
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
@@ -117,8 +9,8 @@ export function MarkdownContent({ content }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={chatMarkdownComponents}
|
||||
remarkPlugins={chatMarkdownRemarkPlugins}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Check, Clipboard } from 'lucide-react';
|
||||
|
||||
function extractText(node: unknown): string {
|
||||
if (!node || typeof node !== 'object') return '';
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n.type === 'text') return String(n.value ?? '');
|
||||
if (Array.isArray(n.children)) {
|
||||
return (n.children as unknown[]).map(extractText).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function CodeBlock({ language, children }: { language: string; children: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
void navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative my-3 overflow-hidden rounded-lg border border-zinc-800 bg-[#0d0d10]">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800/80 px-4 py-1.5">
|
||||
<span className="select-none text-[11px] text-zinc-500">
|
||||
{language || 'text'}
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[11px] text-zinc-500 transition hover:text-zinc-300"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-3" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard className="size-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4">
|
||||
<code className="font-mono text-[13px] leading-relaxed text-zinc-300">
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const chatMarkdownRemarkPlugins = [remarkGfm];
|
||||
|
||||
export const chatMarkdownComponents: Components = {
|
||||
pre({ children, node }) {
|
||||
const nodeChildren =
|
||||
typeof node === 'object' && node !== null && Array.isArray((node as { children?: unknown[] }).children)
|
||||
? (node as { children: unknown[] }).children
|
||||
: undefined;
|
||||
const codeChild = nodeChildren?.[0] as Record<string, unknown> | undefined;
|
||||
|
||||
if (codeChild && (codeChild as Record<string, string>).tagName === 'code') {
|
||||
const classNames = ((codeChild.properties as Record<string, string[]>)?.className ??
|
||||
[]) as string[];
|
||||
const hasLanguage = classNames.some((className) => className.startsWith('language-'));
|
||||
|
||||
if (!hasLanguage) {
|
||||
const text = extractText(codeChild).replace(/\n$/, '');
|
||||
return <CodeBlock language="">{text}</CodeBlock>;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children as ReactNode}</>;
|
||||
},
|
||||
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(String(className ?? ''));
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={match[1]}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-[0.875em] text-zinc-200"
|
||||
{...rest}
|
||||
>
|
||||
{children as ReactNode}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
href={String(href)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children as ReactNode}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CodeNode } from '@lexical/code';
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
||||
import { type Transformer, TRANSFORMERS } from '@lexical/markdown';
|
||||
import { ListItemNode, ListNode } from '@lexical/list';
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
import { type Klass, type LexicalNode } from 'lexical';
|
||||
|
||||
import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage';
|
||||
|
||||
const fencedCodePattern = /(^|\n)```/;
|
||||
const blockMarkdownPattern = /(^|\n)\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+\.\s|\[[ xX]\]\s)/;
|
||||
const tablePattern = /(^|\n)\|.*\|\s*\n\|?[\s:-]+\|/;
|
||||
const linkPattern = /\[([^\]]+)]\(([^)]+)\)/;
|
||||
const inlineFormattingPattern =
|
||||
/(?:`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*\n]+\*|_[^_\n]+_)/;
|
||||
|
||||
export type MarkdownPasteReason =
|
||||
| 'fenced-code'
|
||||
| 'block-structure'
|
||||
| 'table'
|
||||
| 'link'
|
||||
| 'inline-format'
|
||||
| 'plain-text';
|
||||
|
||||
export interface MarkdownPasteInspection {
|
||||
normalizedText: string;
|
||||
shouldImportMarkdown: boolean;
|
||||
reason: MarkdownPasteReason;
|
||||
}
|
||||
|
||||
export const markdownEditorNamespace = 'eryx-markdown-composer';
|
||||
|
||||
export const markdownEditorNodes: ReadonlyArray<Klass<LexicalNode>> = [
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
LinkNode,
|
||||
AutoLinkNode,
|
||||
CodeNode,
|
||||
];
|
||||
|
||||
export const markdownEditorTransformers: ReadonlyArray<Transformer> = TRANSFORMERS;
|
||||
|
||||
export function inspectMarkdownPaste(text: string): MarkdownPasteInspection {
|
||||
const normalizedText = normalizeChatMessageLineEndings(text);
|
||||
|
||||
if (fencedCodePattern.test(normalizedText)) {
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: true,
|
||||
reason: 'fenced-code',
|
||||
};
|
||||
}
|
||||
|
||||
if (blockMarkdownPattern.test(normalizedText)) {
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: true,
|
||||
reason: 'block-structure',
|
||||
};
|
||||
}
|
||||
|
||||
if (tablePattern.test(normalizedText)) {
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: true,
|
||||
reason: 'table',
|
||||
};
|
||||
}
|
||||
|
||||
if (linkPattern.test(normalizedText)) {
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: true,
|
||||
reason: 'link',
|
||||
};
|
||||
}
|
||||
|
||||
if (inlineFormattingPattern.test(normalizedText)) {
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: true,
|
||||
reason: 'inline-format',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedText,
|
||||
shouldImportMarkdown: false,
|
||||
reason: 'plain-text',
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldImportMarkdownPaste(text: string): boolean {
|
||||
return inspectMarkdownPaste(text).shouldImportMarkdown;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionRunRecord,
|
||||
SessionRunStatus,
|
||||
} from '@shared/domain/runTimeline';
|
||||
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
|
||||
|
||||
export function formatRunTimestamp(isoDate: string): string {
|
||||
try {
|
||||
@@ -156,9 +157,7 @@ export function eventSortKey(event: RunTimelineEventRecord): number {
|
||||
|
||||
export function truncateContent(content: string | undefined, maxLength = 80): string | undefined {
|
||||
if (!content) return undefined;
|
||||
const singleLine = content.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) return singleLine;
|
||||
return `${singleLine.slice(0, maxLength)}…`;
|
||||
return buildMarkdownExcerpt(content, maxLength);
|
||||
}
|
||||
|
||||
export function findLatestRun(runs: readonly SessionRunRecord[]): SessionRunRecord | undefined {
|
||||
|
||||
Reference in New Issue
Block a user