Merge pull request #51 from holstein13/feat/session-export
feat: add session export (Markdown, JSON, Plain Text)
This commit is contained in:
commit
05b7888c17
4 changed files with 1299 additions and 0 deletions
142
src/renderer/components/common/ExportDropdown.tsx
Normal file
142
src/renderer/components/common/ExportDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
427
src/renderer/utils/sessionExporter.ts
Normal file
427
src/renderer/utils/sessionExporter.ts
Normal 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);
|
||||
}
|
||||
716
test/renderer/utils/sessionExporter.test.ts
Normal file
716
test/renderer/utils/sessionExporter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue