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).
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
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 type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import { formatSessionAsMarkdown, downloadAsMarkdown, buildExportFilename } from '@/lib/exportSession';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
import MessageList, { type MessageListHandle } from './MessageList';
|
import MessageList, { type MessageListHandle } from './MessageList';
|
||||||
@@ -409,6 +411,23 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
const messageListRef = React.useRef<MessageListHandle | null>(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(() => {
|
const parentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
const current = sessions.find((session) => session.id === currentSessionId);
|
const current = sessions.find((session) => session.id === currentSessionId);
|
||||||
@@ -440,6 +459,20 @@ export const ChatContainer: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const exportButton = !isMobile && sessionMessages.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleExportMarkdown}
|
||||||
|
className="absolute right-3 top-3 z-20 !font-normal text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Export session as Markdown"
|
||||||
|
title="Export session as Markdown"
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
@@ -781,6 +814,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
{returnToParentButton}
|
{returnToParentButton}
|
||||||
|
{exportButton}
|
||||||
<ChatViewport
|
<ChatViewport
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
type SessionMessageRecord = { info: Message; parts: Part[] };
|
||||||
|
|
||||||
|
function extractTextFromParts(parts: Part[]): string {
|
||||||
|
return parts
|
||||||
|
.filter((p): p is Part & { type: 'text'; text: string } => 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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user