From 02a2af6ae2f2413706948b3377b5f31cf5f2e13e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 14:52:52 +0000 Subject: [PATCH 1/6] Add chat markdown export route with tool and attachment support Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-markdown.test.ts | 131 ++++++++++ src/lib/features/chat/chat-markdown.ts | 223 ++++++++++++++++++ .../chat/[chatId].md/+server.ts | 31 +++ 3 files changed, 385 insertions(+) create mode 100644 src/lib/features/chat/chat-markdown.test.ts create mode 100644 src/lib/features/chat/chat-markdown.ts create mode 100644 src/routes/(app)/(chat-data)/(chat-sidebar)/chat/[chatId].md/+server.ts diff --git a/src/lib/features/chat/chat-markdown.test.ts b/src/lib/features/chat/chat-markdown.test.ts new file mode 100644 index 0000000..3230ba5 --- /dev/null +++ b/src/lib/features/chat/chat-markdown.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { + createMarkdownFilename, + renderConversationMarkdown, + type MarkdownConversation +} from './chat-markdown'; +import type { ToolCallPart, ToolResultPart } from 'ai'; + +describe('chat markdown formatter', () => { + it('renders user and assistant messages with attachments and tool calls', () => { + const toolCall: ToolCallPart = { + type: 'tool-call', + toolName: 'fetchLinkContent', + toolCallId: 'tool-1', + input: { + link: 'https://example.com' + } + }; + + const toolResult: ToolResultPart = { + type: 'tool-result', + toolName: 'fetchLinkContent', + toolCallId: 'tool-1', + output: { + type: 'text', + value: '# Example content' + } + }; + + const chat: MarkdownConversation = { + _id: 'chat_123', + title: 'Tooling and Attachments', + messages: [ + { + role: 'user', + content: 'Please summarize this website.', + attachments: [ + { + key: 'user-image.png', + mediaType: 'image/png', + url: 'https://files.example.com/user-image.png' + } + ] + }, + { + role: 'assistant', + parts: [ + { + type: 'text', + text: 'I will open the link and summarize it.' + }, + toolCall, + toolResult, + { + type: 'text', + text: 'Summary: this is example content.' + } + ], + attachments: [ + { + key: 'assistant-image.png', + mediaType: 'image/png', + url: 'https://files.example.com/assistant-image.png' + } + ], + meta: { + stoppedGenerating: Date.now() + } + } + ] + }; + + const markdown = renderConversationMarkdown(chat); + + expect(markdown).toContain('# Tooling and Attachments'); + expect(markdown).toContain('## 1. User'); + expect(markdown).toContain('## 2. Assistant'); + expect(markdown).toContain('### Tool Call: `fetchLinkContent`'); + expect(markdown).toContain('#### Input'); + expect(markdown).toContain('"link": "https://example.com"'); + expect(markdown).toContain('#### Result'); + expect(markdown).toContain('"value": "# Example content"'); + expect(markdown).toContain( + '![Attachment 1](https://files.example.com/user-image.png)' + ); + expect(markdown).toContain( + '![Attachment 1](https://files.example.com/assistant-image.png)' + ); + }); + + it('renders standalone tool results and generating assistant placeholder text', () => { + const chat: MarkdownConversation = { + _id: 'chat_456', + title: 'Tool results only', + messages: [ + { + role: 'assistant', + parts: [ + { + type: 'tool-result', + toolName: 'fetchLinkContent', + toolCallId: 'tool-2', + output: 'Done' + } + ], + attachments: [], + meta: { + stoppedGenerating: Date.now() + } + }, + { + role: 'assistant', + parts: [], + attachments: [], + meta: {} + } + ] + }; + + const markdown = renderConversationMarkdown(chat); + + expect(markdown).toContain('### Tool Result: `fetchLinkContent`'); + expect(markdown).toContain('Done'); + expect(markdown).toContain('_Response is still generating._'); + }); + + it('creates safe markdown filenames', () => { + expect(createMarkdownFilename('My Great Chat!', 'abc123')).toBe('my-great-chat-abc123.md'); + expect(createMarkdownFilename('***', 'id$%^')).toBe('conversation-id.md'); + }); +}); diff --git a/src/lib/features/chat/chat-markdown.ts b/src/lib/features/chat/chat-markdown.ts new file mode 100644 index 0000000..22edd2a --- /dev/null +++ b/src/lib/features/chat/chat-markdown.ts @@ -0,0 +1,223 @@ +import type { ToolCallPart, ToolResultPart } from 'ai'; +import type { StreamResult } from '$lib/utils/stream-transport-protocol'; + +type MarkdownAttachment = { + key: string; + mediaType: string; + url: string; +}; + +type MarkdownUserMessage = { + role: 'user'; + content: string; + attachments: MarkdownAttachment[]; +}; + +type MarkdownAssistantMessage = { + role: 'assistant'; + parts: StreamResult; + attachments: MarkdownAttachment[]; + error?: string; + meta?: { + stoppedGenerating?: number; + }; +}; + +export type MarkdownMessage = MarkdownUserMessage | MarkdownAssistantMessage; + +export type MarkdownConversation = { + _id: string; + title: string; + messages: MarkdownMessage[]; +}; + +const MESSAGE_SEPARATOR = '\n\n---\n\n'; + +export function renderConversationMarkdown(chat: MarkdownConversation): string { + const header = [ + `# ${chat.title}`, + `> Chat ID: \`${chat._id}\``, + `> Messages: ${chat.messages.length}` + ].join('\n\n'); + + const messageSections = chat.messages.map((message, index) => + renderMessageMarkdown(message, index + 1) + ); + + return [header, ...messageSections].join(MESSAGE_SEPARATOR).trimEnd() + '\n'; +} + +export function createMarkdownFilename(title: string, chatId: string): string { + const safeTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + const safeChatId = chatId.replace(/[^a-zA-Z0-9_-]+/g, ''); + return `${safeTitle || 'conversation'}-${safeChatId || 'chat'}.md`; +} + +function renderMessageMarkdown(message: MarkdownMessage, index: number): string { + if (message.role === 'user') { + return renderUserMessageMarkdown(message, index); + } + + return renderAssistantMessageMarkdown(message, index); +} + +function renderUserMessageMarkdown(message: MarkdownUserMessage, index: number): string { + const sections: string[] = [`## ${index}. User`]; + + sections.push(message.content.trim() ? message.content : '_No text content._'); + + const attachmentSection = renderAttachmentsMarkdown(message.attachments); + if (attachmentSection) { + sections.push(attachmentSection); + } + + return sections.join('\n\n'); +} + +function renderAssistantMessageMarkdown(message: MarkdownAssistantMessage, index: number): string { + const sections: string[] = [`## ${index}. Assistant`]; + + if (message.error) { + sections.push(`> Error: ${message.error}`); + } + + sections.push(...renderAssistantPartsMarkdown(message.parts)); + + if (message.parts.length === 0 && !message.error) { + sections.push( + message.meta?.stoppedGenerating === undefined + ? '_Response is still generating._' + : '_No assistant text content._' + ); + } + + const attachmentSection = renderAttachmentsMarkdown(message.attachments); + if (attachmentSection) { + sections.push(attachmentSection); + } + + return sections.join('\n\n'); +} + +function renderAssistantPartsMarkdown(parts: StreamResult): string[] { + const sections: string[] = []; + const toolResultByCallId = new Map(); + const renderedToolResults = new Set(); + + for (const part of parts) { + if (part.type === 'tool-result') { + toolResultByCallId.set(part.toolCallId, part); + } + } + + for (const part of parts) { + if (part.type === 'text') { + if (part.text.trim()) { + sections.push(part.text); + } + } else if (part.type === 'reasoning') { + if (part.text.trim()) { + sections.push(['### Reasoning', part.text].join('\n\n')); + } + } else if (part.type === 'tool-call') { + const result = toolResultByCallId.get(part.toolCallId); + if (result) { + renderedToolResults.add(part.toolCallId); + } + + sections.push(renderToolCallMarkdown(part, result)); + } else if (part.type === 'tool-result') { + if (!renderedToolResults.has(part.toolCallId)) { + sections.push(renderToolResultMarkdown(part)); + } + } + } + + return sections; +} + +function renderToolCallMarkdown(toolCall: ToolCallPart, result?: ToolResultPart): string { + const input = formatForCodeBlock(toolCall.input); + const sections = [ + `### Tool Call: \`${toolCall.toolName}\``, + `- Tool call ID: \`${toolCall.toolCallId}\``, + '#### Input', + createCodeBlock(input.content, input.language) + ]; + + if (!result) { + sections.push('_Tool result pending._'); + return sections.join('\n\n'); + } + + const output = formatForCodeBlock(result.output); + sections.push('#### Result', createCodeBlock(output.content, output.language)); + return sections.join('\n\n'); +} + +function renderToolResultMarkdown(result: ToolResultPart): string { + const output = formatForCodeBlock(result.output); + return [ + `### Tool Result: \`${result.toolName}\``, + `- Tool call ID: \`${result.toolCallId}\``, + '#### Output', + createCodeBlock(output.content, output.language) + ].join('\n\n'); +} + +function renderAttachmentsMarkdown(attachments: MarkdownAttachment[]): string | null { + if (attachments.length === 0) { + return null; + } + + const lines: string[] = ['### Attachments']; + + for (const [index, attachment] of attachments.entries()) { + const label = `Attachment ${index + 1}`; + if (attachment.mediaType.startsWith('image/')) { + lines.push(`- ![${label}](${attachment.url})`); + } else { + lines.push(`- [${label}](${attachment.url})`); + } + lines.push(` - media type: \`${attachment.mediaType}\``); + lines.push(` - key: \`${attachment.key}\``); + } + + return lines.join('\n'); +} + +function formatForCodeBlock(value: unknown): { content: string; language?: string } { + if (typeof value === 'string') { + return { content: value || '(empty)' }; + } + + if ( + typeof value === 'number' || + typeof value === 'boolean' || + value === null || + value === undefined + ) { + return { content: String(value) }; + } + + try { + return { + content: JSON.stringify(value, null, 2), + language: 'json' + }; + } catch { + return { content: String(value) }; + } +} + +function createCodeBlock(content: string, language?: string): string { + const safeContent = content || '(empty)'; + const tickRuns = safeContent.match(/`+/g); + const maxTickRun = tickRuns ? Math.max(...tickRuns.map((run) => run.length)) : 0; + const fence = '`'.repeat(Math.max(3, maxTickRun + 1)); + return `${fence}${language ?? ''}\n${safeContent}\n${fence}`; +} diff --git a/src/routes/(app)/(chat-data)/(chat-sidebar)/chat/[chatId].md/+server.ts b/src/routes/(app)/(chat-data)/(chat-sidebar)/chat/[chatId].md/+server.ts new file mode 100644 index 0000000..a92967d --- /dev/null +++ b/src/routes/(app)/(chat-data)/(chat-sidebar)/chat/[chatId].md/+server.ts @@ -0,0 +1,31 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { api } from '$lib/convex/_generated/api'; +import type { Id } from '$lib/convex/_generated/dataModel'; +import { + createMarkdownFilename, + renderConversationMarkdown +} from '$lib/features/chat/chat-markdown'; + +export const GET: RequestHandler = async ({ params, locals }) => { + // Ensure the Convex client picks up the user's access token for private chats. + await locals.auth(); + + const chatId = params.chatId as Id<'chats'>; + const chat = await locals.convex.query(api.chats.get, { chatId }).catch(() => null); + + if (!chat) { + error(404, 'Chat not found'); + } + + const markdown = renderConversationMarkdown(chat); + const filename = createMarkdownFilename(chat.title, chatId); + + return new Response(markdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `inline; filename="${filename}"`, + 'Cache-Control': 'private, no-store' + } + }); +}; From 69ad512cbdc1c08ac8b666bcf4d3a6a859fc22b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 14:54:21 +0000 Subject: [PATCH 2/6] Fix markdown formatter test tool-result typing Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-markdown.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/features/chat/chat-markdown.test.ts b/src/lib/features/chat/chat-markdown.test.ts index 3230ba5..6777a09 100644 --- a/src/lib/features/chat/chat-markdown.test.ts +++ b/src/lib/features/chat/chat-markdown.test.ts @@ -100,7 +100,10 @@ describe('chat markdown formatter', () => { type: 'tool-result', toolName: 'fetchLinkContent', toolCallId: 'tool-2', - output: 'Done' + output: { + type: 'text', + value: 'Done' + } } ], attachments: [], @@ -120,7 +123,7 @@ describe('chat markdown formatter', () => { const markdown = renderConversationMarkdown(chat); expect(markdown).toContain('### Tool Result: `fetchLinkContent`'); - expect(markdown).toContain('Done'); + expect(markdown).toContain('"value": "Done"'); expect(markdown).toContain('_Response is still generating._'); }); From 759ea3cb0929f10a254f466e6c8a57e504128680 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 15:30:57 +0000 Subject: [PATCH 3/6] Add markdown share link UI and simplify markdown header Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-markdown.test.ts | 1 + src/lib/features/chat/chat-markdown.ts | 6 +----- src/lib/features/chat/chat-share-button.svelte | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/features/chat/chat-markdown.test.ts b/src/lib/features/chat/chat-markdown.test.ts index 6777a09..023b31b 100644 --- a/src/lib/features/chat/chat-markdown.test.ts +++ b/src/lib/features/chat/chat-markdown.test.ts @@ -73,6 +73,7 @@ describe('chat markdown formatter', () => { const markdown = renderConversationMarkdown(chat); expect(markdown).toContain('# Tooling and Attachments'); + expect(markdown).not.toContain('Chat ID'); expect(markdown).toContain('## 1. User'); expect(markdown).toContain('## 2. Assistant'); expect(markdown).toContain('### Tool Call: `fetchLinkContent`'); diff --git a/src/lib/features/chat/chat-markdown.ts b/src/lib/features/chat/chat-markdown.ts index 22edd2a..5d01ea8 100644 --- a/src/lib/features/chat/chat-markdown.ts +++ b/src/lib/features/chat/chat-markdown.ts @@ -34,11 +34,7 @@ export type MarkdownConversation = { const MESSAGE_SEPARATOR = '\n\n---\n\n'; export function renderConversationMarkdown(chat: MarkdownConversation): string { - const header = [ - `# ${chat.title}`, - `> Chat ID: \`${chat._id}\``, - `> Messages: ${chat.messages.length}` - ].join('\n\n'); + const header = [`# ${chat.title}`, `> Messages: ${chat.messages.length}`].join('\n\n'); const messageSections = chat.messages.map((message, index) => renderMessageMarkdown(message, index + 1) diff --git a/src/lib/features/chat/chat-share-button.svelte b/src/lib/features/chat/chat-share-button.svelte index c4fc58b..c3dea7c 100644 --- a/src/lib/features/chat/chat-share-button.svelte +++ b/src/lib/features/chat/chat-share-button.svelte @@ -41,6 +41,8 @@ const sharePath = $derived(`/chat/${chat._id}`); const shareUrl = $derived(new URL(sharePath, page.url.origin).toString()); + const markdownSharePath = $derived(`/chat/${chat._id}.md`); + const markdownShareUrl = $derived(new URL(markdownSharePath, page.url.origin).toString()); {#if isMobile.current} @@ -89,6 +91,7 @@ Share + {@render shareWithAgent()} {:else} + {@render shareWithAgent()} {/if} @@ -146,11 +150,21 @@ OG + {@render shareWithAgent()} {/if} {/if} +{#snippet shareWithAgent()} + +
--- OR ---
+
+

Share with your agent

+ +
+{/snippet} + {#snippet option({ icon: Icon, label, From 099b2d2417cefcf9af6c842ec10952893356f94b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 15:38:01 +0000 Subject: [PATCH 4/6] Center OR label inside share separator Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-share-button.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/features/chat/chat-share-button.svelte b/src/lib/features/chat/chat-share-button.svelte index c3dea7c..41d480d 100644 --- a/src/lib/features/chat/chat-share-button.svelte +++ b/src/lib/features/chat/chat-share-button.svelte @@ -157,8 +157,14 @@ {/if} {#snippet shareWithAgent()} - -
--- OR ---
+
+ + + OR + +

Share with your agent

From d0cfe17b28b1d4797d7a88e1d64124c506208119 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 15:42:05 +0000 Subject: [PATCH 5/6] Use popover background for OR separator label Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-share-button.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/features/chat/chat-share-button.svelte b/src/lib/features/chat/chat-share-button.svelte index 41d480d..183087b 100644 --- a/src/lib/features/chat/chat-share-button.svelte +++ b/src/lib/features/chat/chat-share-button.svelte @@ -160,7 +160,7 @@
OR From 67e5b3808d46e4d914664933acade2c25a6ea763 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 15:54:58 +0000 Subject: [PATCH 6/6] Format markdown formatter tests with Prettier Co-authored-by: Aidan Bleser --- src/lib/features/chat/chat-markdown.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/features/chat/chat-markdown.test.ts b/src/lib/features/chat/chat-markdown.test.ts index 023b31b..f8b1b6e 100644 --- a/src/lib/features/chat/chat-markdown.test.ts +++ b/src/lib/features/chat/chat-markdown.test.ts @@ -81,12 +81,8 @@ describe('chat markdown formatter', () => { expect(markdown).toContain('"link": "https://example.com"'); expect(markdown).toContain('#### Result'); expect(markdown).toContain('"value": "# Example content"'); - expect(markdown).toContain( - '![Attachment 1](https://files.example.com/user-image.png)' - ); - expect(markdown).toContain( - '![Attachment 1](https://files.example.com/assistant-image.png)' - ); + expect(markdown).toContain('![Attachment 1](https://files.example.com/user-image.png)'); + expect(markdown).toContain('![Attachment 1](https://files.example.com/assistant-image.png)'); }); it('renders standalone tool results and generating assistant placeholder text', () => {