agent-ecosystem/src/renderer/components/common/ExportDropdown.tsx
Paul Holstein d3b7d9dfeb feat: add session export (Markdown, JSON, Plain Text)
Add an export button to the TabBar header that lets users export
the current session as Markdown, JSON, or Plain Text. The button
appears between Search and Notifications, only for session tabs.

- sessionExporter.ts: formatters for all three formats + download trigger
- ExportDropdown.tsx: dropdown UI component with format selection
- TabBar.tsx: integration with conditional rendering for session tabs
- 51 new tests covering all formatters, edge cases, and download

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:18:52 -05:00

142 lines
4.6 KiB
TypeScript

/**
* ExportDropdown - Download icon button with dropdown for exporting session data.
*
* Supports three formats: Markdown (.md), JSON (.json), Plain Text (.txt).
* Follows the same close-on-outside-click / Escape patterns as RepositoryDropdown.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { triggerDownload } from '@renderer/utils/sessionExporter';
import { Braces, Download, FileText, Type } from 'lucide-react';
import type { SessionDetail } from '@renderer/types/data';
import type { ExportFormat } from '@renderer/utils/sessionExporter';
interface ExportDropdownProps {
sessionDetail: SessionDetail;
}
interface FormatOption {
format: ExportFormat;
label: string;
icon: React.ComponentType<{ className?: string }>;
ext: string;
}
const FORMAT_OPTIONS: FormatOption[] = [
{ format: 'markdown', label: 'Markdown', icon: FileText, ext: '.md' },
{ format: 'json', label: 'JSON', icon: Braces, ext: '.json' },
{ format: 'plaintext', label: 'Plain Text', icon: Type, ext: '.txt' },
];
export const ExportDropdown = ({
sessionDetail,
}: Readonly<ExportDropdownProps>): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredFormat, setHoveredFormat] = useState<ExportFormat | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
const handleExport = useCallback(
(format: ExportFormat) => {
triggerDownload(sessionDetail, format);
setIsOpen(false);
},
[sessionDetail]
);
return (
<div ref={containerRef} className="relative">
{/* Trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
onMouseEnter={() => setButtonHover(true)}
onMouseLeave={() => setButtonHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Export session"
>
<Download className="size-4" />
</button>
{/* Dropdown menu */}
{isOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-48 overflow-hidden rounded-md border shadow-lg"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border)',
}}
>
{/* Header */}
<div
className="px-3 py-2 text-xs font-medium"
style={{
color: 'var(--color-text-secondary)',
borderBottom: '1px solid var(--color-border)',
}}
>
Export Session
</div>
{/* Format options */}
{FORMAT_OPTIONS.map((option) => (
<button
key={option.format}
onClick={() => handleExport(option.format)}
onMouseEnter={() => setHoveredFormat(option.format)}
onMouseLeave={() => setHoveredFormat(null)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors"
style={{
color:
hoveredFormat === option.format
? 'var(--color-text)'
: 'var(--color-text-secondary)',
backgroundColor:
hoveredFormat === option.format ? 'var(--color-surface-raised)' : 'transparent',
}}
>
<option.icon className="size-3.5" />
<span className="flex-1">{option.label}</span>
<span className="text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{option.ext}
</span>
</button>
))}
</div>
)}
</div>
);
};