From a17d159e18dfcc9f654ae1fb7ac9aeff497f5cc4 Mon Sep 17 00:00:00 2001 From: coldbrow <78590713+coldbrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:50:26 +0900 Subject: [PATCH] feat(chat): add export session as markdown workflow (#934) Add a download button in the chat view that exports the current session's messages as a formatted Markdown file. User and assistant turns are separated by horizontal rules. The filename is derived from the session title and today's date (e.g. fix-login-bug-2026-04-17.md). --- .../ui/src/components/chat/ChatContainer.tsx | 36 +++++++++++- packages/ui/src/lib/exportSession.ts | 58 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/lib/exportSession.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 1d06eded..de8ceef2 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { RiArrowLeftLine } from '@remixicon/react'; +import { RiArrowLeftLine, RiDownloadLine } from '@remixicon/react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; import { useUIStore } from '@/stores/useUIStore'; +import { toast } from '@/components/ui'; +import { formatSessionAsMarkdown, downloadAsMarkdown, buildExportFilename } from '@/lib/exportSession'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList, { type MessageListHandle } from './MessageList'; @@ -409,6 +411,23 @@ export const ChatContainer: React.FC = () => { const isDesktopExpandedInput = isExpandedInput && !isMobile; const messageListRef = React.useRef(null); + const currentSession = React.useMemo( + () => currentSessionId ? sessions.find((s) => s.id === currentSessionId) ?? null : null, + [currentSessionId, sessions], + ); + + const handleExportMarkdown = React.useCallback(() => { + if (!currentSessionId || sessionMessages.length === 0) { + toast.error('Nothing to export'); + return; + } + const title = currentSession?.title ?? null; + const markdown = formatSessionAsMarkdown(sessionMessages, title); + const filename = buildExportFilename(title); + downloadAsMarkdown(markdown, filename); + toast.success('Session exported'); + }, [currentSessionId, currentSession, sessionMessages]); + const parentSession = React.useMemo(() => { if (!currentSessionId) return null; const current = sessions.find((session) => session.id === currentSessionId); @@ -440,6 +459,20 @@ export const ChatContainer: React.FC = () => { ) : null; + const exportButton = !isMobile && sessionMessages.length > 0 ? ( + + ) : null; + React.useEffect(() => { if (!currentSessionId && !draftOpen) { openNewSessionDraft(); @@ -781,6 +814,7 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > {returnToParentButton} + {exportButton} p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text) + .join(''); +} + +function formatMessageAsMarkdown(record: SessionMessageRecord): string { + const role = record.info.role === 'user' ? '### User' : '### Assistant'; + const text = extractTextFromParts(record.parts).trim(); + + if (!text) return ''; + return `${role}\n\n${text}`; +} + +export function formatSessionAsMarkdown( + messages: SessionMessageRecord[], + sessionTitle?: string | null, +): string { + const title = sessionTitle?.trim() || 'Session'; + const date = new Date().toISOString().split('T')[0]; + + const header = `# ${title}\n\n*Exported on ${date}*\n\n---\n\n`; + + const body = messages + .map(formatMessageAsMarkdown) + .filter(Boolean) + .join('\n\n---\n\n'); + + return header + body; +} + +export function downloadAsMarkdown(content: string, filename: string): void { + const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +export function buildExportFilename(sessionTitle?: string | null): string { + const base = sessionTitle?.trim() || 'session'; + const safe = base + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + const date = new Date().toISOString().split('T')[0]; + return `${safe}-${date}.md`; +}