Merge pull request #51 from holstein13/feat/session-export

feat: add session export (Markdown, JSON, Plain Text)
This commit is contained in:
matt 2026-02-22 01:33:15 +09:00 committed by GitHub
commit 05b7888c17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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');
});
});