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>
This commit is contained in:
Paul Holstein 2026-02-21 11:18:52 -05:00
parent ffa94f5e0f
commit d3b7d9dfeb
4 changed files with 1299 additions and 0 deletions

View file

@ -0,0 +1,142 @@
/**
* 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>
);
};

View file

@ -17,6 +17,8 @@ import { useStore } from '@renderer/store';
import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ExportDropdown } from '../common/ExportDropdown';
import { SortableTab } from './SortableTab';
import { TabContextMenu } from './TabContextMenu';
@ -50,6 +52,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
pinnedSessionIds,
toggleHideSession,
hiddenSessionIds,
tabSessionData,
} = useStore(
useShallow((s) => ({
pane: s.paneLayout.panes.find((p) => p.id === paneId),
@ -76,6 +79,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
pinnedSessionIds: s.pinnedSessionIds,
toggleHideSession: s.toggleHideSession,
hiddenSessionIds: s.hiddenSessionIds,
tabSessionData: s.tabSessionData,
}))
);
@ -89,6 +93,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
// Derive stable tab IDs array for SortableContext
const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);
// Derive session detail for the active tab (used by export dropdown)
const activeTabSessionDetail = activeTabId
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
: null;
// Hover states for buttons
const [expandHover, setExpandHover] = useState(false);
const [refreshHover, setRefreshHover] = useState(false);
@ -372,6 +381,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<Search className="size-4" />
</button>
{/* Export dropdown - show only for session tabs with loaded data */}
{activeTab?.type === 'session' && activeTabSessionDetail && (
<ExportDropdown sessionDetail={activeTabSessionDetail} />
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}

View file

@ -0,0 +1,427 @@
/**
* Session export utilities for claude-devtools.
*
* Provides formatters to export session data as plain text, Markdown, or JSON,
* and a download trigger for browser-based file saving.
*/
import type { Chunk, SessionDetail } from '@renderer/types/data';
import type { ContentBlock } from '@shared/types';
// =============================================================================
// Types
// =============================================================================
export type ExportFormat = 'markdown' | 'json' | 'plaintext';
interface ExtractOptions {
includeThinking?: boolean;
}
// =============================================================================
// Helpers (not exported)
// =============================================================================
function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}
function formatCost(cost?: number): string {
if (cost == null) return 'N/A';
return `$${cost.toFixed(2)}`;
}
function formatTimestamp(date: Date): string {
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC');
}
function formatDurationForExport(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${remainSecs}s`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '...';
}
// =============================================================================
// extractTextFromContent
// =============================================================================
/**
* Extract readable text from message content (string or ContentBlock[]).
*
* @param content - String content or array of ContentBlocks
* @param options - Options controlling extraction behavior
* @returns Extracted text with newlines between blocks
*/
export function extractTextFromContent(
content: string | ContentBlock[],
options?: ExtractOptions
): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content) || content.length === 0) {
return '';
}
const parts: string[] = [];
for (const block of content) {
switch (block.type) {
case 'text':
parts.push(block.text);
break;
case 'thinking':
if (options?.includeThinking) {
parts.push(block.thinking);
}
break;
case 'tool_use':
parts.push(`Tool: ${block.name}\nInput: ${JSON.stringify(block.input, null, 2)}`);
break;
case 'tool_result': {
const resultContent = block.content;
if (typeof resultContent === 'string') {
parts.push(resultContent);
} else if (Array.isArray(resultContent)) {
// Recursively extract from nested content blocks
const nested = extractTextFromContent(resultContent);
if (nested) parts.push(nested);
}
break;
}
case 'image':
parts.push('[Image]');
break;
}
}
return parts.join('\n');
}
// =============================================================================
// Plain Text Chunk Formatters
// =============================================================================
function formatToolExecutionPlainText(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(` TOOL: ${exec.toolCall.name}`);
lines.push(` Input: ${JSON.stringify(exec.toolCall.input)}`);
if (exec.result) {
const prefix = exec.result.isError ? ' [ERROR] Result: ' : ' Result: ';
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content);
lines.push(`${prefix}${truncate(resultText, 500)}`);
} else {
lines.push(' [No result]');
}
return lines;
}
function formatChunkPlainText(chunk: Chunk): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`USER: ${extractTextFromContent(chunk.userMessage.content)}`);
break;
}
case 'ai': {
// Render thinking blocks first, then text
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Check for thinking blocks
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push(`THINKING: ${block.thinking}`);
}
}
// Then text
const text = extractTextFromContent(response.content);
if (text) {
lines.push(`ASSISTANT: ${text}`);
}
} else if (typeof response.content === 'string') {
lines.push(`ASSISTANT: ${response.content}`);
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionPlainText(exec));
}
break;
}
case 'system': {
lines.push(`SYSTEM: ${chunk.commandOutput}`);
break;
}
case 'compact': {
lines.push('[Context compacted]');
break;
}
}
return lines;
}
// =============================================================================
// Markdown Chunk Formatters
// =============================================================================
function formatToolExecutionMarkdown(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(`**Tool:** \`${exec.toolCall.name}\``);
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(exec.toolCall.input, null, 2));
lines.push('```');
lines.push('');
if (exec.result) {
if (exec.result.isError) {
lines.push('**Error:**');
} else {
lines.push('**Result:**');
}
lines.push('');
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content, null, 2);
lines.push('```');
lines.push(truncate(resultText, 2000));
lines.push('```');
}
return lines;
}
function formatChunkMarkdown(chunk: Chunk, turnNum: number): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`### User (Turn ${turnNum})`);
lines.push('');
lines.push(extractTextFromContent(chunk.userMessage.content));
lines.push('');
break;
}
case 'ai': {
lines.push(`### Assistant (Turn ${turnNum})`);
lines.push('');
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Thinking blocks as blockquotes
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push('> *Thinking:*');
for (const thinkLine of block.thinking.split('\n')) {
lines.push(`> ${thinkLine}`);
}
lines.push('');
}
}
// Text content
const text = extractTextFromContent(response.content);
if (text) {
lines.push(text);
lines.push('');
}
} else if (typeof response.content === 'string') {
lines.push(response.content);
lines.push('');
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionMarkdown(exec));
lines.push('');
}
break;
}
case 'system': {
lines.push(`### System (Turn ${turnNum})`);
lines.push('');
lines.push(chunk.commandOutput);
lines.push('');
break;
}
case 'compact': {
lines.push('---');
lines.push('');
lines.push('*Context compacted*');
lines.push('');
break;
}
}
return lines;
}
// =============================================================================
// Export Functions
// =============================================================================
/**
* Export session as plain text transcript.
*
* Produces a flat text format with clear labels (USER:, ASSISTANT:, TOOL:, etc.)
* and separator lines between sections.
*/
export function exportAsPlainText(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Header
lines.push('═'.repeat(60));
lines.push('SESSION EXPORT');
lines.push('═'.repeat(60));
lines.push(`Session: ${session.id}`);
lines.push(`Project: ${session.projectPath}`);
if (session.gitBranch) {
lines.push(`Branch: ${session.gitBranch}`);
}
lines.push(`Date: ${formatTimestamp(new Date(session.createdAt))}`);
lines.push('');
// Metrics
lines.push('─'.repeat(40));
lines.push('METRICS');
lines.push('─'.repeat(40));
lines.push(`Duration: ${formatDurationForExport(metrics.durationMs)}`);
lines.push(`Total Tokens: ${formatNumber(metrics.totalTokens)}`);
lines.push(`Input Tokens: ${formatNumber(metrics.inputTokens)}`);
lines.push(`Output Tokens: ${formatNumber(metrics.outputTokens)}`);
lines.push(`Cache Read: ${formatNumber(metrics.cacheReadTokens)}`);
lines.push(`Cache Created: ${formatNumber(metrics.cacheCreationTokens)}`);
lines.push(`Messages: ${formatNumber(metrics.messageCount)}`);
lines.push(`Cost: ${formatCost(metrics.costUsd)}`);
lines.push('');
// Conversation
lines.push('═'.repeat(60));
lines.push('CONVERSATION');
lines.push('═'.repeat(60));
lines.push('');
for (const chunk of chunks) {
lines.push('─'.repeat(40));
lines.push(...formatChunkPlainText(chunk));
lines.push('');
}
return lines.join('\n');
}
/**
* Export session as structured Markdown.
*
* Produces Markdown with headings, tables, code blocks, and blockquotes
* suitable for viewing in any Markdown renderer.
*/
export function exportAsMarkdown(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Title
lines.push('# Session Export');
lines.push('');
// Property table
lines.push('| Property | Value |');
lines.push('|----------|-------|');
lines.push(`| Session | \`${session.id}\` |`);
lines.push(`| Project | \`${session.projectPath}\` |`);
if (session.gitBranch) {
lines.push(`| Branch | \`${session.gitBranch}\` |`);
}
lines.push(`| Date | ${formatTimestamp(new Date(session.createdAt))} |`);
lines.push('');
// Metrics table
lines.push('## Metrics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Duration | ${formatDurationForExport(metrics.durationMs)} |`);
lines.push(`| Total Tokens | ${formatNumber(metrics.totalTokens)} |`);
lines.push(`| Input Tokens | ${formatNumber(metrics.inputTokens)} |`);
lines.push(`| Output Tokens | ${formatNumber(metrics.outputTokens)} |`);
lines.push(`| Cache Read | ${formatNumber(metrics.cacheReadTokens)} |`);
lines.push(`| Cache Created | ${formatNumber(metrics.cacheCreationTokens)} |`);
lines.push(`| Messages | ${formatNumber(metrics.messageCount)} |`);
lines.push(`| Cost | ${formatCost(metrics.costUsd)} |`);
lines.push('');
// Conversation
lines.push('## Conversation');
lines.push('');
let turnNum = 0;
for (const chunk of chunks) {
turnNum++;
lines.push(...formatChunkMarkdown(chunk, turnNum));
}
return lines.join('\n');
}
/**
* Export session as pretty-printed JSON.
*/
export function exportAsJson(detail: SessionDetail): string {
return JSON.stringify(detail, null, 2);
}
/**
* Trigger a browser file download for the given session in the specified format.
*
* Creates a Blob, generates an object URL, and simulates an anchor click
* to initiate the download.
*/
export function triggerDownload(detail: SessionDetail, format: ExportFormat): void {
const formatters: Record<
ExportFormat,
{ fn: (d: SessionDetail) => string; ext: string; mime: string }
> = {
markdown: { fn: exportAsMarkdown, ext: 'md', mime: 'text/markdown;charset=utf-8' },
json: { fn: exportAsJson, ext: 'json', mime: 'application/json;charset=utf-8' },
plaintext: { fn: exportAsPlainText, ext: 'txt', mime: 'text/plain;charset=utf-8' },
};
const { fn, ext, mime } = formatters[format];
const content = fn(detail);
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `session-${detail.session.id}.${ext}`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,716 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
extractTextFromContent,
exportAsPlainText,
exportAsMarkdown,
exportAsJson,
triggerDownload,
type ExportFormat,
} from '@renderer/utils/sessionExporter';
// =============================================================================
// Test Fixtures
// =============================================================================
function makeMetrics(overrides = {}) {
return {
durationMs: 60000,
totalTokens: 5000,
inputTokens: 3000,
outputTokens: 2000,
cacheReadTokens: 500,
cacheCreationTokens: 100,
messageCount: 10,
costUsd: 0.05,
...overrides,
};
}
function makeSession(overrides = {}) {
return {
id: 'test-session-123',
projectId: '-Users-test-project',
projectPath: '/Users/test/project',
createdAt: new Date('2025-01-15T10:00:00Z').getTime(),
hasSubagents: false,
messageCount: 10,
firstMessage: 'Hello, help me debug this',
gitBranch: 'main',
...overrides,
};
}
function makeMessage(overrides: Record<string, unknown> = {}) {
return {
uuid: 'msg-1',
parentUuid: null,
type: 'user' as const,
timestamp: new Date('2025-01-15T10:00:00Z'),
content: 'Hello world',
isMeta: false,
isSidechain: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
function makeUserChunk(overrides: Record<string, unknown> = {}) {
const msg = makeMessage();
return {
id: 'chunk-user-1',
chunkType: 'user' as const,
startTime: new Date('2025-01-15T10:00:00Z'),
endTime: new Date('2025-01-15T10:00:01Z'),
durationMs: 1000,
metrics: makeMetrics({ messageCount: 1 }),
userMessage: msg,
...overrides,
};
}
function makeAIChunk(overrides: Record<string, unknown> = {}) {
const response = makeMessage({
uuid: 'msg-2',
type: 'assistant',
content: [{ type: 'text', text: 'Here is the answer' }],
});
return {
id: 'chunk-ai-1',
chunkType: 'ai' as const,
startTime: new Date('2025-01-15T10:00:01Z'),
endTime: new Date('2025-01-15T10:00:05Z'),
durationMs: 4000,
metrics: makeMetrics({ messageCount: 2 }),
responses: [response],
processes: [],
sidechainMessages: [],
toolExecutions: [],
...overrides,
};
}
function makeSystemChunk(overrides: Record<string, unknown> = {}) {
return {
id: 'chunk-system-1',
chunkType: 'system' as const,
startTime: new Date('2025-01-15T10:00:06Z'),
endTime: new Date('2025-01-15T10:00:07Z'),
durationMs: 1000,
metrics: makeMetrics({ messageCount: 1 }),
message: makeMessage({ type: 'user', content: 'command output here' }),
commandOutput: 'Set model to sonnet',
...overrides,
};
}
function makeCompactChunk(overrides: Record<string, unknown> = {}) {
return {
id: 'chunk-compact-1',
chunkType: 'compact' as const,
startTime: new Date('2025-01-15T10:01:00Z'),
endTime: new Date('2025-01-15T10:01:00Z'),
durationMs: 0,
metrics: makeMetrics({ messageCount: 0 }),
message: makeMessage({ type: 'summary', content: 'Summary of conversation' }),
...overrides,
};
}
function makeSessionDetail(overrides: Record<string, unknown> = {}) {
const userChunk = makeUserChunk();
const aiChunk = makeAIChunk();
return {
session: makeSession(),
messages: [userChunk.userMessage as any, (aiChunk.responses as any)[0]],
chunks: [userChunk, aiChunk],
processes: [],
metrics: makeMetrics(),
...overrides,
};
}
// =============================================================================
// extractTextFromContent
// =============================================================================
describe('extractTextFromContent', () => {
it('returns string content directly', () => {
expect(extractTextFromContent('Hello world')).toBe('Hello world');
});
it('returns empty string for empty string', () => {
expect(extractTextFromContent('')).toBe('');
});
it('extracts text from TextContent blocks', () => {
const blocks = [
{ type: 'text', text: 'First part.' },
{ type: 'text', text: 'Second part.' },
];
expect(extractTextFromContent(blocks as any)).toBe('First part.\nSecond part.');
});
it('includes thinking content when option is set', () => {
const blocks = [
{ type: 'thinking', thinking: 'Let me think about this...' },
{ type: 'text', text: 'Answer here.' },
];
expect(extractTextFromContent(blocks as any, { includeThinking: true })).toBe(
'Let me think about this...\nAnswer here.'
);
});
it('excludes thinking content by default', () => {
const blocks = [
{ type: 'thinking', thinking: 'Let me think...' },
{ type: 'text', text: 'Answer here.' },
];
expect(extractTextFromContent(blocks as any)).toBe('Answer here.');
});
it('extracts tool_use content as formatted string', () => {
const blocks = [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/foo.ts' } }];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('Tool: Read');
expect(result).toContain('/foo.ts');
});
it('extracts tool_result content', () => {
const blocks = [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file contents here' }];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('file contents here');
});
it('handles tool_result with array content', () => {
const blocks = [
{
type: 'tool_result',
tool_use_id: 'tu-1',
content: [{ type: 'text', text: 'result text' }],
},
];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('result text');
});
it('skips image blocks gracefully', () => {
const blocks = [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } },
{ type: 'text', text: 'Caption' },
];
expect(extractTextFromContent(blocks as any)).toBe('[Image]\nCaption');
});
it('returns empty string for empty array', () => {
expect(extractTextFromContent([])).toBe('');
});
});
// =============================================================================
// exportAsPlainText
// =============================================================================
describe('exportAsPlainText', () => {
it('includes session header with metadata', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('SESSION EXPORT');
expect(result).toContain('test-session-123');
expect(result).toContain('/Users/test/project');
});
it('includes metrics section', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('METRICS');
expect(result).toContain('5,000');
expect(result).toContain('$0.05');
});
it('renders user chunks with USER: label', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('USER:');
expect(result).toContain('Hello world');
});
it('renders AI chunks with ASSISTANT: label', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('ASSISTANT:');
expect(result).toContain('Here is the answer');
});
it('renders system chunks with SYSTEM: label', () => {
const detail = makeSessionDetail({
chunks: [makeSystemChunk()],
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('SYSTEM:');
expect(result).toContain('Set model to sonnet');
});
it('renders compact chunks as [Context compacted]', () => {
const detail = makeSessionDetail({
chunks: [makeCompactChunk()],
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('[Context compacted]');
});
it('renders tool executions with TOOL: label', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: {
id: 'tu-1',
name: 'Read',
input: { file_path: '/src/main.ts' },
isTask: false,
},
result: { toolUseId: 'tu-1', content: 'file content', isError: false },
startTime: new Date('2025-01-15T10:00:02Z'),
endTime: new Date('2025-01-15T10:00:03Z'),
durationMs: 1000,
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('TOOL: Read');
expect(result).toContain('/src/main.ts');
expect(result).toContain('file content');
});
it('renders thinking blocks with THINKING: label', () => {
const aiChunk = makeAIChunk({
responses: [
makeMessage({
uuid: 'msg-think',
type: 'assistant',
content: [
{ type: 'thinking', thinking: 'Let me reason about this...' },
{ type: 'text', text: 'Final answer.' },
],
}),
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('THINKING:');
expect(result).toContain('Let me reason about this...');
expect(result).toContain('Final answer.');
});
it('handles tool execution with error result', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'rm -rf /' }, isTask: false },
result: { toolUseId: 'tu-1', content: 'Permission denied', isError: true },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('TOOL: Bash');
expect(result).toContain('[ERROR]');
expect(result).toContain('Permission denied');
});
it('uses separator lines between chunks', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
// Should contain horizontal rule separators
expect(result).toMatch(/─{20,}/);
});
it('formats cost as N/A when undefined', () => {
const detail = makeSessionDetail({
metrics: makeMetrics({ costUsd: undefined }),
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('N/A');
});
it('includes branch info when available', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('main');
});
});
// =============================================================================
// exportAsMarkdown
// =============================================================================
describe('exportAsMarkdown', () => {
it('starts with # Session Export heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toMatch(/^# Session Export/);
});
it('includes property table with session info', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('| Property | Value |');
expect(result).toContain('test-session-123');
expect(result).toContain('/Users/test/project');
expect(result).toContain('main');
});
it('includes ## Metrics table', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('## Metrics');
expect(result).toContain('| Metric | Value |');
expect(result).toContain('5,000');
expect(result).toContain('$0.05');
});
it('includes ## Conversation section', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('## Conversation');
});
it('renders user chunks with ### User heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### User');
expect(result).toContain('Hello world');
});
it('renders AI chunks with ### Assistant heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### Assistant');
expect(result).toContain('Here is the answer');
});
it('renders system chunks with ### System heading', () => {
const detail = makeSessionDetail({
chunks: [makeSystemChunk()],
});
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### System');
expect(result).toContain('Set model to sonnet');
});
it('renders compact chunks with --- and italic text', () => {
const detail = makeSessionDetail({
chunks: [makeCompactChunk()],
});
const result = exportAsMarkdown(detail as any);
expect(result).toContain('---');
expect(result).toContain('*Context compacted*');
});
it('renders tool calls with **Tool:** and code blocks', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: {
id: 'tu-1',
name: 'Read',
input: { file_path: '/src/app.ts' },
isTask: false,
},
result: { toolUseId: 'tu-1', content: 'export default App;', isError: false },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('**Tool:** `Read`');
expect(result).toContain('```json');
expect(result).toContain('file_path');
expect(result).toContain('```');
expect(result).toContain('export default App;');
});
it('renders thinking as blockquotes', () => {
const aiChunk = makeAIChunk({
responses: [
makeMessage({
uuid: 'msg-think',
type: 'assistant',
content: [
{ type: 'thinking', thinking: 'Deep thought here' },
{ type: 'text', text: 'Output text' },
],
}),
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('> *Thinking:*');
expect(result).toContain('> Deep thought here');
});
it('marks error tool results', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'fail' }, isTask: false },
result: { toolUseId: 'tu-1', content: 'Error: not found', isError: true },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('**Error:**');
});
it('numbers turns sequentially', () => {
const detail = makeSessionDetail({
chunks: [
makeUserChunk(),
makeAIChunk(),
makeUserChunk({ id: 'chunk-user-2' }),
makeAIChunk({ id: 'chunk-ai-2' }),
],
});
const result = exportAsMarkdown(detail as any);
// Check that turn numbers appear (Turn 1, Turn 2, etc.)
const turnMatches = result.match(/### (User|Assistant)/g);
expect(turnMatches).toBeTruthy();
expect(turnMatches!.length).toBeGreaterThanOrEqual(2);
});
});
// =============================================================================
// exportAsJson
// =============================================================================
describe('exportAsJson', () => {
it('returns valid JSON', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
expect(() => JSON.parse(result)).not.toThrow();
});
it('returns pretty-printed JSON with 2-space indentation', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
// Pretty-printed JSON has newlines and indentation
expect(result).toContain('\n');
expect(result).toContain(' ');
});
it('preserves session data', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.session.id).toBe('test-session-123');
expect(parsed.session.projectPath).toBe('/Users/test/project');
});
it('preserves metrics', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.metrics.totalTokens).toBe(5000);
expect(parsed.metrics.costUsd).toBe(0.05);
});
it('preserves chunks array', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.chunks).toBeDefined();
expect(Array.isArray(parsed.chunks)).toBe(true);
expect(parsed.chunks.length).toBe(2);
});
it('preserves messages array', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.messages).toBeDefined();
expect(Array.isArray(parsed.messages)).toBe(true);
});
});
// =============================================================================
// triggerDownload
// =============================================================================
describe('triggerDownload', () => {
let createElementSpy: ReturnType<typeof vi.spyOn>;
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockAnchor = {
href: '',
download: '',
click: vi.fn(),
};
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any);
vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor as any);
vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor as any);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
it('creates anchor element and triggers click for markdown', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'markdown');
expect(createElementSpy).toHaveBeenCalledWith('a');
expect(mockAnchor.download).toBe('session-test-session-123.md');
expect(mockAnchor.click).toHaveBeenCalled();
});
it('uses .json extension for json format', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'json');
expect(mockAnchor.download).toBe('session-test-session-123.json');
});
it('uses .txt extension for plaintext format', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'plaintext');
expect(mockAnchor.download).toBe('session-test-session-123.txt');
});
it('creates and revokes object URL', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'markdown');
expect(URL.createObjectURL).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('appends and removes anchor from body', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'plaintext');
expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
// =============================================================================
// Edge cases
// =============================================================================
describe('edge cases', () => {
it('handles empty chunks array', () => {
const detail = makeSessionDetail({ chunks: [], messages: [] });
expect(() => exportAsPlainText(detail as any)).not.toThrow();
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
expect(() => exportAsJson(detail as any)).not.toThrow();
});
it('handles AI chunk with no tool executions', () => {
const aiChunk = makeAIChunk({ toolExecutions: [] });
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const text = exportAsPlainText(detail as any);
expect(text).toContain('ASSISTANT:');
expect(text).not.toContain('TOOL:');
});
it('handles AI chunk with no responses', () => {
const aiChunk = makeAIChunk({ responses: [] });
const detail = makeSessionDetail({ chunks: [aiChunk] });
expect(() => exportAsPlainText(detail as any)).not.toThrow();
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
});
it('handles tool execution without result', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'ls' }, isTask: false },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const text = exportAsPlainText(detail as any);
expect(text).toContain('TOOL: Bash');
expect(text).toContain('[No result]');
});
it('handles mixed chunk types in sequence', () => {
const detail = makeSessionDetail({
chunks: [
makeUserChunk(),
makeAIChunk(),
makeSystemChunk(),
makeCompactChunk(),
makeUserChunk({ id: 'chunk-user-2' }),
makeAIChunk({ id: 'chunk-ai-2' }),
],
});
const text = exportAsPlainText(detail as any);
expect(text).toContain('USER:');
expect(text).toContain('ASSISTANT:');
expect(text).toContain('SYSTEM:');
expect(text).toContain('[Context compacted]');
const md = exportAsMarkdown(detail as any);
expect(md).toContain('### User');
expect(md).toContain('### Assistant');
expect(md).toContain('### System');
expect(md).toContain('*Context compacted*');
});
it('handles content blocks with mixed types', () => {
const blocks = [
{ type: 'thinking', thinking: 'Hmm...' },
{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/a.ts' } },
{ type: 'text', text: 'Result text' },
{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file data' },
];
const result = extractTextFromContent(blocks as any, { includeThinking: true });
expect(result).toContain('Hmm...');
expect(result).toContain('Tool: Read');
expect(result).toContain('Result text');
expect(result).toContain('file data');
});
});