feat: add markdown composer backend foundation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 19:19:58 +01:00
co-authored by Copilot
parent a8c6ba94f5
commit d07bf30e81
16 changed files with 539 additions and 121 deletions
+4 -3
View File
@@ -57,6 +57,7 @@ import {
type ChatMessageRecord,
type SessionRecord,
} from '@shared/domain/session';
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
import {
appendRunActivityEvent,
completeSessionRunRecord,
@@ -442,8 +443,8 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = await this.buildEffectivePattern(project, pattern, session);
const trimmed = content.trim();
if (!trimmed) {
const preparedContent = prepareChatMessageContent(content);
if (!preparedContent) {
return;
}
@@ -455,7 +456,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
id: userMessageId,
role: 'user',
authorName: 'You',
content: trimmed,
content: preparedContent,
createdAt: occurredAt,
});
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
+5 -3
View File
@@ -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"
>
+3 -111
View File
@@ -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>
+114
View File
@@ -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>
);
},
};
+97
View File
@@ -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;
}
+2 -3
View File
@@ -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 {
+2 -1
View File
@@ -4,6 +4,7 @@ import {
type ApprovalPolicy,
validateApprovalPolicy,
} from '@shared/domain/approval';
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
export type OrchestrationMode =
| 'single'
@@ -1009,5 +1010,5 @@ export function buildSessionTitle(pattern: PatternDefinition, messages: ChatMess
return pattern.name;
}
return firstUserMessage.content.slice(0, 48).trim() || pattern.name;
return buildMarkdownExcerpt(firstUserMessage.content, 48) ?? pattern.name;
}
+12
View File
@@ -0,0 +1,12 @@
export function normalizeChatMessageLineEndings(value: string): string {
return value.replace(/\r\n?/g, '\n');
}
export function hasMeaningfulChatMessageContent(value: string): boolean {
return normalizeChatMessageLineEndings(value).trim().length > 0;
}
export function prepareChatMessageContent(value: string): string | undefined {
const normalized = normalizeChatMessageLineEndings(value);
return normalized.trim().length > 0 ? normalized : undefined;
}
+58
View File
@@ -0,0 +1,58 @@
import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage';
const fencedCodeBlockPattern = /```[^\n]*\n([\s\S]*?)```/g;
const inlineCodePattern = /`([^`]+)`/g;
const imagePattern = /!\[([^\]]*)]\([^)]+\)/g;
const linkPattern = /\[([^\]]+)]\([^)]+\)/g;
const autoLinkPattern = /<((?:https?:\/\/|mailto:)[^>]+)>/g;
const headingPattern = /^\s{0,3}#{1,6}\s+/gm;
const blockquotePattern = /^\s{0,3}>\s?/gm;
const listPattern = /^\s{0,3}(?:[-*+]|\d+\.)\s+/gm;
const checklistPattern = /^\s*\[[ xX]\]\s+/gm;
const setextHeadingPattern = /^[=-]{2,}\s*$/gm;
const thematicBreakPattern = /^[ \t]{0,3}(?:[-*_][ \t]*){3,}$/gm;
const tableSeparatorPattern = /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/gm;
const emphasisPattern = /(\*\*|__|~~|\*|_)/g;
const markdownEscapePattern = /\\([\\`*_{}\[\]()#+\-.!>])/g;
export function extractPlainTextFromMarkdown(markdown: string): string {
const normalized = normalizeChatMessageLineEndings(markdown);
const plain = normalized
.replace(fencedCodeBlockPattern, '$1')
.replace(inlineCodePattern, '$1')
.replace(imagePattern, '$1')
.replace(linkPattern, '$1')
.replace(autoLinkPattern, '$1')
.replace(headingPattern, '')
.replace(blockquotePattern, '')
.replace(listPattern, '')
.replace(checklistPattern, '')
.replace(setextHeadingPattern, ' ')
.replace(thematicBreakPattern, ' ')
.replace(tableSeparatorPattern, ' ')
.replace(/\|/g, ' ')
.replace(emphasisPattern, '')
.replace(markdownEscapePattern, '$1')
.replace(/\s+/g, ' ')
.trim();
return plain;
}
export function buildMarkdownExcerpt(markdown: string | undefined, maxLength = 80): string | undefined {
if (!markdown) {
return undefined;
}
const plainText = extractPlainTextFromMarkdown(markdown);
if (!plainText) {
return undefined;
}
if (plainText.length <= maxLength) {
return plainText;
}
return `${plainText.slice(0, maxLength).trimEnd()}`;
}