diff --git a/web/src/components/plan/ContextPopover.test.tsx b/web/src/components/plan/ContextPopover.test.tsx index 013584ad..4f791590 100644 --- a/web/src/components/plan/ContextPopover.test.tsx +++ b/web/src/components/plan/ContextPopover.test.tsx @@ -4,8 +4,9 @@ import { render, screen, cleanup, fireEvent } from '@testing-library/react' import { ContextPopover } from './ContextPopover' import { SessionScopeProvider } from '../../stores/session/session-scope' -const { sendMock } = vi.hoisted(() => ({ +const { sendMock, exportConversationMock } = vi.hoisted(() => ({ sendMock: vi.fn(), + exportConversationMock: vi.fn(), })) let storeState: Record = {} @@ -14,6 +15,10 @@ vi.mock('../../stores/session', () => ({ useSessionStore: (selector: (state: unknown) => unknown) => selector(storeState), })) +vi.mock('../../lib/export-conversation', () => ({ + exportConversation: (...args: unknown[]) => exportConversationMock(...args), +})) + vi.mock('../../lib/ws', () => ({ wsClient: { send: (...args: unknown[]) => { @@ -185,4 +190,29 @@ describe('ContextPopover', () => { ) expect(screen.queryByText('Update system prompt')).toBeNull() }) + + it('renders "Export all conversation" button and triggers export on click (popover variant)', () => { + render( + + + , + ) + const exportBtn = screen.getByText('Export all conversation') + expect(exportBtn).toBeDefined() + fireEvent.click(exportBtn) + expect(exportConversationMock).toHaveBeenCalledWith('s1', expect.anything()) + }) + + it('renders "Export all conversation" button in sidebar menu and triggers export on click', () => { + render( + + + , + ) + fireEvent.click(screen.getByTitle('More options')) + const exportBtn = screen.getByText('Export all conversation') + expect(exportBtn).toBeDefined() + fireEvent.click(exportBtn) + expect(exportConversationMock).toHaveBeenCalledWith('s1', expect.anything()) + }) }) diff --git a/web/src/components/plan/ContextPopover.tsx b/web/src/components/plan/ContextPopover.tsx index 568a68ce..1c3b2a76 100644 --- a/web/src/components/plan/ContextPopover.tsx +++ b/web/src/components/plan/ContextPopover.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from '../../stores/session' import { useT } from '../../hooks/useT' import { ProgressBar, LowTokenWarning } from '../shared/ProgressBar' import { formatTokens } from '../../lib/format-stats' +import { exportConversation } from '../../lib/export-conversation' import { MoreIcon } from '../shared/icons' import { getTextColor } from './token-utils' import { DynamicContextPreviewModal } from './DynamicContextPreviewModal' @@ -113,6 +114,19 @@ export function ContextPopover({ variant = 'popover', onUpdateSystemPrompt }: Co {needsRebase && } + )} @@ -181,6 +195,18 @@ export function ContextPopover({ variant = 'popover', onUpdateSystemPrompt }: Co {needsRebase && } + {applyModal} diff --git a/web/src/lib/export-conversation.test.ts b/web/src/lib/export-conversation.test.ts new file mode 100644 index 00000000..d7dfe8f5 --- /dev/null +++ b/web/src/lib/export-conversation.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { sanitizeFilename, formatConversationMarkdown, downloadFile, exportConversation } from './export-conversation' +import type { Message, Session } from '@shared/types.js' + +describe('export-conversation', () => { + describe('sanitizeFilename', () => { + it('cleans invalid characters', () => { + expect(sanitizeFilename('My / Great : File * Name?')).toBe('My_Great_File_Name_') + expect(sanitizeFilename('SimpleName')).toBe('SimpleName') + }) + }) + + describe('formatConversationMarkdown', () => { + it('formats metadata, user message, assistant message with thinking, tool calls, and sub-agents', () => { + const session: Partial = { + id: 'sess-123', + projectId: 'proj-abc', + workdir: '/dev/app', + providerId: 'provider-1', + providerModel: 'gpt-4o', + mode: 'build', + createdAt: '2026-09-01T10:00:00.000Z', + metadata: { + title: 'Fix issue with login', + totalTokensUsed: 100, + totalToolCalls: 2, + iterationCount: 1, + }, + } + + const messages: Message[] = [ + { + id: 'm1', + role: 'user', + content: 'Hello, please inspect the files.', + timestamp: '2026-09-01T10:01:00.000Z', + }, + { + id: 'm2', + role: 'assistant', + content: 'I will explore the codebase.', + thinkingContent: 'Need to run ls tool.', + timestamp: '2026-09-01T10:02:00.000Z', + toolCalls: [ + { + id: 'tc1', + name: 'run_command', + arguments: { command: 'ls' }, + result: { + success: true, + output: 'src\npackage.json', + durationMs: 12, + truncated: false, + }, + }, + ], + }, + { + id: 'm3', + role: 'assistant', + subAgentId: 'sub-1', + subAgentType: 'explorer', + content: 'Explorer found files.', + timestamp: '2026-09-01T10:03:00.000Z', + toolCalls: [ + { + id: 'tc2', + name: 'read_file', + arguments: { path: 'src/index.ts' }, + result: { + success: false, + error: 'File not found', + durationMs: 5, + truncated: false, + }, + }, + ], + }, + ] + + const md = formatConversationMarkdown(session, messages) + + expect(md).toContain('# Fix issue with login') + expect(md).toContain('**Session ID:** `sess-123`') + expect(md).toContain('**Model:** `gpt-4o` (provider-1)') + expect(md).toContain('### 👤 User') + expect(md).toContain('Hello, please inspect the files.') + expect(md).toContain('### 🤖 Assistant') + expect(md).toContain('> **Thinking:**') + expect(md).toContain('Need to run ls tool.') + expect(md).toContain('#### 🛠️ Tool: `run_command`') + expect(md).toContain('"command": "ls"') + expect(md).toContain('src\npackage.json') + expect(md).toContain('### 🤖 Sub-Agent [explorer] (`sub-1`)') + expect(md).toContain('Explorer found files.') + expect(md).toContain('#### 🛠️ Tool: `read_file`') + expect(md).toContain('Error: File not found') + }) + }) + + describe('downloadFile & exportConversation', () => { + let createObjectURLMock: ReturnType + let revokeObjectURLMock: ReturnType + let clickMock: ReturnType + + beforeEach(() => { + createObjectURLMock = vi.fn(() => 'blob:mock-url') + revokeObjectURLMock = vi.fn() + global.URL.createObjectURL = createObjectURLMock as unknown as typeof URL.createObjectURL + global.URL.revokeObjectURL = revokeObjectURLMock as unknown as typeof URL.revokeObjectURL + + clickMock = vi.fn() + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + if (tagName === 'a') { + return { + href: '', + download: '', + click: clickMock, + } as unknown as HTMLAnchorElement + } + return document.createElement(tagName) + }) + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node) + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('downloads file correctly via DOM anchor element', () => { + downloadFile('test content', 'file.md') + expect(createObjectURLMock).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url') + }) + + it('fetches full session data and exports conversation', async () => { + const mockSession = { id: 's1', metadata: { title: 'Test Session' } } + const mockMessages = [{ id: 'm1', role: 'user', content: 'test' }] as Message[] + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ session: mockSession, messages: mockMessages }), + }) + + await exportConversation('s1') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/sessions/s1?full=true'), + expect.anything(), + ) + expect(clickMock).toHaveBeenCalled() + }) + }) +}) diff --git a/web/src/lib/export-conversation.ts b/web/src/lib/export-conversation.ts new file mode 100644 index 00000000..6150f58a --- /dev/null +++ b/web/src/lib/export-conversation.ts @@ -0,0 +1,160 @@ +import type { Message, Session, ToolCall } from '@shared/types.js' +import { authFetch } from './api' + +export function sanitizeFilename(name: string): string { + return name + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_+/g, '_') + .slice(0, 80) +} + +function formatToolCalls(toolCalls: ToolCall[]): string[] { + const result: string[] = [] + for (const tc of toolCalls) { + result.push(`#### 🛠️ Tool: \`${tc.name}\``) + if (tc.arguments && Object.keys(tc.arguments).length > 0) { + result.push('```json\n' + JSON.stringify(tc.arguments, null, 2) + '\n```') + } + if (tc.result) { + if (tc.result.success) { + result.push('**Result (Success):**\n```\n' + (tc.result.output ?? '') + '\n```') + } else { + result.push( + '**Result (Error):**\n```\n' + + (tc.result.error ? `Error: ${tc.result.error}\n` : '') + + (tc.result.output ?? '') + + '\n```', + ) + } + } + result.push('') + } + return result +} + +function formatThinking(thinking: string): string { + return ( + '> **Thinking:**\n' + + thinking + .split('\n') + .map((l) => `> ${l}`) + .join('\n') + + '\n' + ) +} + +export function formatConversationMarkdown(session: Partial | null, messages: Message[]): string { + const lines: string[] = [] + + // Header + const title = session?.metadata?.title || session?.id || 'Conversation' + lines.push(`# ${title}\n`) + if (session?.id) lines.push(`- **Session ID:** \`${session.id}\``) + if (session?.createdAt) lines.push(`- **Created:** ${session.createdAt}`) + if (session?.projectId) lines.push(`- **Project:** \`${session.projectId}\``) + if (session?.workdir) lines.push(`- **Workdir:** \`${session.workdir}\``) + if (session?.providerModel) { + lines.push(`- **Model:** \`${session.providerModel}\`${session.providerId ? ` (${session.providerId})` : ''}`) + } + if (session?.mode) lines.push(`- **Mode:** \`${session.mode}\``) + lines.push(`- **Exported At:** ${new Date().toISOString()}`) + lines.push('\n---\n') + + let currentWindowId: string | undefined + + for (const msg of messages) { + if (msg.role === 'tool') continue + + // Context window divider + if (msg.contextWindowId && currentWindowId && msg.contextWindowId !== currentWindowId) { + lines.push('\n---\n*Context Compaction / Window Transition*\n---\n') + } + currentWindowId = msg.contextWindowId + + const time = msg.timestamp ? ` *(${msg.timestamp})*` : '' + + if (msg.role === 'user') { + lines.push(`### 👤 User${time}\n`) + if (msg.content) { + lines.push(msg.content) + } + if (msg.attachments && msg.attachments.length > 0) { + lines.push('\n**Attachments:**') + for (const att of msg.attachments) { + lines.push(`- ${att.filename || 'Attachment'} (${att.mimeType || 'unknown'})`) + } + } + lines.push('\n') + } else if (msg.subAgentId || msg.subAgentType) { + const agentLabel = msg.subAgentType ? `Sub-Agent [${msg.subAgentType}]` : 'Sub-Agent' + lines.push(`### 🤖 ${agentLabel}${msg.subAgentId ? ` (\`${msg.subAgentId}\`)` : ''}${time}\n`) + + if (msg.thinkingContent) { + lines.push(formatThinking(msg.thinkingContent)) + } + if (msg.content) { + lines.push(msg.content + '\n') + } + if (msg.toolCalls && msg.toolCalls.length > 0) { + lines.push(...formatToolCalls(msg.toolCalls)) + } + } else if (msg.role === 'assistant') { + lines.push(`### 🤖 Assistant${time}\n`) + if (msg.thinkingContent) { + lines.push(formatThinking(msg.thinkingContent)) + } + if (msg.content) { + lines.push(msg.content + '\n') + } + if (msg.toolCalls && msg.toolCalls.length > 0) { + lines.push(...formatToolCalls(msg.toolCalls)) + } + } else if (msg.role === 'system' || msg.isSystemGenerated) { + lines.push(`### ⚙️ System${msg.messageKind ? ` (${msg.messageKind})` : ''}${time}\n`) + if (msg.content) { + lines.push(msg.content + '\n') + } + } + } + + return lines.join('\n') +} + +export function downloadFile(content: string, filename: string, mimeType = 'text/markdown;charset=utf-8'): void { + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +export async function exportConversation( + sessionId: string, + fallbackSession?: Partial | null, + fallbackMessages?: Message[], +): Promise { + let session = fallbackSession ?? null + let messages = fallbackMessages ?? [] + + try { + const res = await authFetch(`/api/sessions/${sessionId}?full=true`) + if (res.ok) { + const data = await res.json() + if (data.session) session = data.session + if (data.messages && Array.isArray(data.messages)) messages = data.messages + } + } catch (err) { + console.warn('Failed to fetch full session history, using local fallback:', err) + } + + const markdown = formatConversationMarkdown(session, messages) + const rawTitle = session?.metadata?.title || sessionId + const dateStr = new Date().toISOString().slice(0, 10) + const filename = `${sanitizeFilename(rawTitle)}_${dateStr}.md` + + downloadFile(markdown, filename) +}