Skip to content

Commit c6a68c0

Browse files
jeremyederclaudeAmbient Code Bot
authored
feat: add Export Chat (Markdown / PDF) to session kebab menu (#625)
## Summary - Adds "Export chat" submenu to the session kebab menu (both `kebab-only` and `full` render modes) with two options: **As Markdown** and **As PDF** - New `utils/export-chat.ts` processes AG-UI events client-side into structured markdown with metadata header, User/Assistant sections, and collapsible `<details>` blocks for tool calls - PDF export renders styled HTML in a new window and triggers `window.print()` (browser native "Save as PDF") - Deduplicates the blob-download pattern: extracts shared `triggerDownload()` utility, now used by both new export and existing JSON export in `session-details-modal.tsx` - Fixes XSS vector in PDF path (HTML-escapes message content before rendering) and double-print bug (guard flag) ## Files changed | File | Change | |------|--------| | `components/frontend/src/utils/export-chat.ts` | **New** — `convertEventsToMarkdown`, `downloadAsMarkdown`, `exportAsPdf`, `triggerDownload` | | `components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx` | Add Export chat `DropdownMenuSub` with loading states | | `components/frontend/src/components/session-details-modal.tsx` | Replace inline `downloadFile` with shared `triggerDownload` import | ## Test plan - [ ] Open a session with chat history, click kebab menu → verify "Export chat" submenu appears between Clone and Delete - [ ] Click "As Markdown" → verify `.md` file downloads with readable conversation (metadata table, User/Assistant headings, tool calls as `<details>`) - [ ] Click "As PDF" → verify print dialog opens with styled conversation in a new tab - [ ] Verify existing "Export Chat" (JSON) button in View Details modal still works after `triggerDownload` refactor - [ ] Verify `npm run build` passes with 0 errors, 0 warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Ambient Code Bot <bot@ambient-code.local>
1 parent 253af3f commit c6a68c0

File tree

9 files changed

+667
-34
lines changed

9 files changed

+667
-34
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* MCP Invoke API Route
3+
* POST /api/projects/:name/agentic-sessions/:sessionName/mcp/invoke
4+
* Proxies to backend which proxies to runner to invoke an MCP tool
5+
*/
6+
7+
import { BACKEND_URL } from '@/lib/config'
8+
import { buildForwardHeadersAsync } from '@/lib/auth'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
export async function POST(
13+
request: Request,
14+
{ params }: { params: Promise<{ name: string; sessionName: string }> },
15+
) {
16+
const { name, sessionName } = await params
17+
18+
const headers = await buildForwardHeadersAsync(request, {
19+
'Content-Type': 'application/json',
20+
})
21+
22+
const body = await request.text()
23+
24+
const backendUrl = `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/mcp/invoke`
25+
26+
try {
27+
const response = await fetch(backendUrl, {
28+
method: 'POST',
29+
headers,
30+
body,
31+
})
32+
33+
if (!response.ok) {
34+
const errorText = await response.text()
35+
// Preserve structured JSON errors from backend; wrap plain text
36+
let errorBody: string
37+
try {
38+
const parsed = JSON.parse(errorText)
39+
errorBody = JSON.stringify(parsed)
40+
} catch {
41+
errorBody = JSON.stringify({ error: errorText || `HTTP ${response.status}` })
42+
}
43+
return new Response(errorBody, {
44+
status: response.status,
45+
headers: { 'Content-Type': 'application/json' },
46+
})
47+
}
48+
49+
const data = await response.json()
50+
return Response.json(data)
51+
} catch (error) {
52+
console.error('MCP invoke proxy error:', error)
53+
return new Response(
54+
JSON.stringify({
55+
error: error instanceof Error ? error.message : 'Failed to invoke MCP tool',
56+
}),
57+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
58+
)
59+
}
60+
}

components/frontend/src/app/globals.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
@import "tailwindcss";
2-
@import "tw-animate-css";
32
@import "../styles/syntax-highlighting.css";
43

54
@custom-variant dark (&:is(.dark *));

components/frontend/src/app/projects/[name]/sessions/[sessionName]/session-header.tsx

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@
22

33
import { useState } from 'react';
44
import { Button } from '@/components/ui/button';
5-
import { RefreshCw, Octagon, Trash2, Copy, MoreVertical, Info, Play, Pencil } from 'lucide-react';
5+
import { RefreshCw, Octagon, Trash2, Copy, MoreVertical, Info, Play, Pencil, Download, FileText, Printer, Loader2, HardDrive } from 'lucide-react';
66
import { CloneSessionDialog } from '@/components/clone-session-dialog';
77
import { SessionDetailsModal } from '@/components/session-details-modal';
88
import { EditSessionNameDialog } from '@/components/edit-session-name-dialog';
9-
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
9+
import {
10+
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
11+
DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
12+
} from '@/components/ui/dropdown-menu';
1013
import type { AgenticSession } from '@/types/agentic-session';
11-
import { useUpdateSessionDisplayName } from '@/services/queries';
14+
import { useUpdateSessionDisplayName, useCurrentUser, useSessionExport } from '@/services/queries';
15+
import { useMcpStatus } from '@/services/queries/use-mcp';
16+
import { useGoogleStatus } from '@/services/queries/use-google';
1217
import { successToast, errorToast } from '@/hooks/use-toast';
18+
import { saveToGoogleDrive } from '@/services/api/sessions';
19+
import { convertEventsToMarkdown, downloadAsMarkdown, exportAsPdf } from '@/utils/export-chat';
1320

1421
type SessionHeaderProps = {
1522
session: AgenticSession;
@@ -34,14 +41,25 @@ export function SessionHeader({
3441
}: SessionHeaderProps) {
3542
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
3643
const [editNameDialogOpen, setEditNameDialogOpen] = useState(false);
37-
44+
const [exportLoading, setExportLoading] = useState<'markdown' | 'pdf' | 'gdrive' | null>(null);
45+
3846
const updateDisplayNameMutation = useUpdateSessionDisplayName();
39-
47+
const { data: me } = useCurrentUser();
48+
4049
const phase = session.status?.phase || "Pending";
41-
const canStop = phase === "Running" || phase === "Creating";
50+
const isRunning = phase === "Running";
51+
const canStop = isRunning || phase === "Creating";
4252
const canResume = phase === "Stopped";
4353
const canDelete = phase === "Completed" || phase === "Failed" || phase === "Stopped";
44-
54+
55+
const { refetch: fetchExportData } = useSessionExport(projectName, session.metadata.name, false);
56+
const { data: mcpStatus } = useMcpStatus(projectName, session.metadata.name, isRunning);
57+
const { data: googleStatus } = useGoogleStatus();
58+
const googleDriveServer = mcpStatus?.servers?.find(
59+
(s) => s.name.includes('gdrive') || s.name.includes('google-drive') || s.name.includes('google-workspace')
60+
);
61+
const hasGdriveMcp = !!googleDriveServer;
62+
4563
const handleEditName = (newName: string) => {
4664
updateDisplayNameMutation.mutate(
4765
{
@@ -62,6 +80,104 @@ export function SessionHeader({
6280
);
6381
};
6482

83+
const handleExport = async (format: 'markdown' | 'pdf' | 'gdrive') => {
84+
if (format === 'gdrive') {
85+
if (!googleStatus?.connected) {
86+
errorToast('Connect Google Drive in Integrations first');
87+
return;
88+
}
89+
if (!isRunning || !hasGdriveMcp) {
90+
errorToast('Session must be running with Google Drive MCP configured');
91+
return;
92+
}
93+
}
94+
95+
setExportLoading(format);
96+
try {
97+
const { data: exportData } = await fetchExportData();
98+
if (!exportData) {
99+
throw new Error('No export data available');
100+
}
101+
const markdown = convertEventsToMarkdown(exportData, session, {
102+
username: me?.displayName || me?.username || me?.email,
103+
projectName,
104+
});
105+
const filename = session.spec.displayName || session.metadata.name;
106+
107+
switch (format) {
108+
case 'markdown':
109+
downloadAsMarkdown(markdown, `${filename}.md`);
110+
successToast('Chat exported as Markdown');
111+
break;
112+
case 'pdf':
113+
exportAsPdf(markdown, filename);
114+
break;
115+
case 'gdrive': {
116+
const result = await saveToGoogleDrive(
117+
projectName, session.metadata.name, markdown,
118+
`${filename}.md`, me?.email ?? '', googleDriveServer?.name ?? 'google-workspace',
119+
);
120+
if (result.error) {
121+
throw new Error(result.error);
122+
}
123+
if (!result.content) {
124+
throw new Error('Failed to create file in Google Drive');
125+
}
126+
successToast('Saved to Google Drive');
127+
break;
128+
}
129+
}
130+
} catch (err) {
131+
errorToast(err instanceof Error ? err.message : 'Failed to export chat');
132+
} finally {
133+
setExportLoading(null);
134+
}
135+
};
136+
137+
const exportSubMenu = (
138+
<DropdownMenuSub>
139+
<DropdownMenuSubTrigger>
140+
<Download className="w-4 h-4 mr-2" />
141+
Export chat
142+
</DropdownMenuSubTrigger>
143+
<DropdownMenuSubContent>
144+
<DropdownMenuItem
145+
onClick={() => void handleExport('markdown')}
146+
disabled={exportLoading !== null}
147+
>
148+
{exportLoading === 'markdown' ? (
149+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
150+
) : (
151+
<FileText className="w-4 h-4 mr-2" />
152+
)}
153+
As Markdown
154+
</DropdownMenuItem>
155+
<DropdownMenuItem
156+
onClick={() => void handleExport('pdf')}
157+
disabled={exportLoading !== null}
158+
>
159+
{exportLoading === 'pdf' ? (
160+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
161+
) : (
162+
<Printer className="w-4 h-4 mr-2" />
163+
)}
164+
As PDF
165+
</DropdownMenuItem>
166+
<DropdownMenuItem
167+
onClick={() => void handleExport('gdrive')}
168+
disabled={exportLoading !== null}
169+
>
170+
{exportLoading === 'gdrive' ? (
171+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
172+
) : (
173+
<HardDrive className="w-4 h-4 mr-2" />
174+
)}
175+
Save to my Google Drive
176+
</DropdownMenuItem>
177+
</DropdownMenuSubContent>
178+
</DropdownMenuSub>
179+
);
180+
65181
// Kebab menu only (for breadcrumb line)
66182
if (renderMode === 'kebab-only') {
67183
return (
@@ -116,6 +232,7 @@ export function SessionHeader({
116232
}
117233
projectName={projectName}
118234
/>
235+
{exportSubMenu}
119236
{canDelete && (
120237
<>
121238
<DropdownMenuSeparator />
@@ -131,14 +248,14 @@ export function SessionHeader({
131248
)}
132249
</DropdownMenuContent>
133250
</DropdownMenu>
134-
251+
135252
<SessionDetailsModal
136253
session={session}
137254
projectName={projectName}
138255
open={detailsModalOpen}
139256
onOpenChange={setDetailsModalOpen}
140257
/>
141-
258+
142259
<EditSessionNameDialog
143260
open={editNameDialogOpen}
144261
onOpenChange={setEditNameDialogOpen}
@@ -224,7 +341,7 @@ export function SessionHeader({
224341
Resume
225342
</Button>
226343
)}
227-
344+
228345
{/* Actions dropdown menu */}
229346
<DropdownMenu>
230347
<DropdownMenuTrigger asChild>
@@ -252,6 +369,7 @@ export function SessionHeader({
252369
}
253370
projectName={projectName}
254371
/>
372+
{exportSubMenu}
255373
{canDelete && (
256374
<>
257375
<DropdownMenuSeparator />
@@ -276,7 +394,7 @@ export function SessionHeader({
276394
open={detailsModalOpen}
277395
onOpenChange={setDetailsModalOpen}
278396
/>
279-
397+
280398
<EditSessionNameDialog
281399
open={editNameDialogOpen}
282400
onOpenChange={setEditNameDialogOpen}

components/frontend/src/components/session-details-modal.tsx

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useCallback, useState } from 'react';
3+
import { useState } from 'react';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
55
import { Badge } from '@/components/ui/badge';
66
import { Button } from '@/components/ui/button';
@@ -10,6 +10,7 @@ import type { AgenticSession } from '@/types/agentic-session';
1010
import { getPhaseColor } from '@/utils/session-helpers';
1111
import { successToast } from '@/hooks/use-toast';
1212
import { useSessionExport } from '@/services/queries/use-sessions';
13+
import { triggerDownload } from '@/utils/export-chat';
1314

1415
type SessionDetailsModalProps = {
1516
session: AgenticSession;
@@ -35,37 +36,27 @@ export function SessionDetailsModal({
3536
open // Only fetch when modal is open
3637
);
3738

38-
const downloadFile = useCallback((data: unknown, filename: string) => {
39-
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
40-
const url = URL.createObjectURL(blob);
41-
const link = document.createElement('a');
42-
link.href = url;
43-
link.download = filename;
44-
link.click();
45-
URL.revokeObjectURL(url);
46-
}, []);
47-
48-
const handleExportAgui = useCallback(() => {
39+
const handleExportAgui = () => {
4940
if (!exportData) return;
5041
setExportingAgui(true);
5142
try {
52-
downloadFile(exportData.aguiEvents, `${sessionName}-chat.json`);
43+
triggerDownload(JSON.stringify(exportData.aguiEvents, null, 2), `${sessionName}-chat.json`, 'application/json');
5344
successToast('Chat exported successfully');
5445
} finally {
5546
setExportingAgui(false);
5647
}
57-
}, [exportData, sessionName, downloadFile]);
48+
};
5849

59-
const handleExportLegacy = useCallback(() => {
50+
const handleExportLegacy = () => {
6051
if (!exportData?.legacyMessages) return;
6152
setExportingLegacy(true);
6253
try {
63-
downloadFile(exportData.legacyMessages, `${sessionName}-legacy-messages.json`);
54+
triggerDownload(JSON.stringify(exportData.legacyMessages, null, 2), `${sessionName}-legacy-messages.json`, 'application/json');
6455
successToast('Legacy messages exported successfully');
6556
} finally {
6657
setExportingLegacy(false);
6758
}
68-
}, [exportData, sessionName, downloadFile]);
59+
};
6960

7061
return (
7162
<Dialog open={open} onOpenChange={onOpenChange}>

components/frontend/src/components/ui/toaster.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function Toaster() {
1414
const { toasts } = useToast()
1515

1616
return (
17-
<ToastProvider>
17+
<ToastProvider duration={5000}>
1818
{toasts.map(function ({ id, title, description, action, ...props }) {
1919
return (
2020
<Toast key={id} {...props}>

components/frontend/src/services/api/sessions.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,32 @@ export async function getReposStatus(
257257
`/projects/${projectName}/agentic-sessions/${sessionName}/repos/status`
258258
);
259259
}
260+
261+
/**
262+
* Response from Google Drive file creation
263+
*/
264+
export type GoogleDriveFileResponse = {
265+
content?: string;
266+
error?: string;
267+
};
268+
269+
/**
270+
* Save content to Google Drive via the session's MCP server
271+
*/
272+
export async function saveToGoogleDrive(
273+
projectName: string,
274+
sessionName: string,
275+
content: string,
276+
filename: string,
277+
userEmail: string,
278+
serverName: string = 'google-workspace',
279+
): Promise<GoogleDriveFileResponse> {
280+
return apiClient.post<GoogleDriveFileResponse>(
281+
`/projects/${projectName}/agentic-sessions/${sessionName}/mcp/invoke`,
282+
{
283+
server: serverName,
284+
tool: 'create_drive_file',
285+
args: { user_google_email: userEmail, file_name: filename, content, mime_type: 'text/markdown' },
286+
},
287+
);
288+
}

components/frontend/src/services/queries/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './use-secrets';
1212
export * from './use-repo';
1313
export * from './use-workspace';
1414
export * from './use-auth';
15+
export * from './use-google';

0 commit comments

Comments
 (0)