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`; +}