diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 481c5e5d..ed8b1a7c 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -8,6 +8,7 @@ import {
listProviders,
} from './oauth-handler.js';
import { watcher as watcherCore, workspace } from '@x/core';
+import { WorkDir } from '@x/core/dist/config/config.js';
import { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
import * as runsCore from '@x/core/dist/runs/runs.js';
@@ -549,6 +550,35 @@ export function setupIpcHandlers() {
await runsCore.deleteRun(args.runId);
return { success: true };
},
+ 'runs:downloadLog': async (event, args) => {
+ const runFileName = `${args.runId}.jsonl`;
+ if (path.basename(runFileName) !== runFileName) {
+ return { success: false, error: 'Invalid run id' };
+ }
+
+ const sourcePath = path.join(WorkDir, 'runs', runFileName);
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const result = await dialog.showSaveDialog(win!, {
+ defaultPath: `${runFileName}.log`,
+ filters: [
+ { name: 'Chat Log', extensions: ['log'] },
+ { name: 'JSONL', extensions: ['jsonl'] },
+ { name: 'All Files', extensions: ['*'] },
+ ],
+ });
+
+ if (result.canceled || !result.filePath) {
+ return { success: false };
+ }
+
+ try {
+ await fs.copyFile(sourcePath, result.filePath);
+ return { success: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to download chat log';
+ return { success: false, error: message };
+ }
+ },
'models:list': async () => {
if (await isSignedIn()) {
return await listGatewayModels();
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index f7a50074..f758e180 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
-import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
+import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@@ -65,6 +65,12 @@ import {
} from "@/components/ui/sidebar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
@@ -5186,6 +5192,25 @@ function App() {
if (tabId === activeChatTabId) return activeChatTabState
return chatViewStateByTab[tabId] ?? emptyChatTabState
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
+ const activeRunIdForDownload = activeChatTabState.runId
+ const handleDownloadActiveChatLog = useCallback(async () => {
+ if (!activeRunIdForDownload) {
+ toast.error('No chat log available yet')
+ return
+ }
+
+ try {
+ const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunIdForDownload })
+ if (result.success) {
+ toast.success('Chat log saved')
+ } else if (result.error) {
+ toast.error(result.error)
+ }
+ } catch (err) {
+ console.error('Download chat log failed:', err)
+ toast.error('Failed to download chat log')
+ }
+ }, [activeRunIdForDownload])
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
@@ -5373,6 +5398,33 @@ function App() {
New chat
)}
+
+
+
+
+
+
+
+ Chat options
+
+
+ {
+ void handleDownloadActiveChatLog()
+ }}
+ >
+
+ Download chat log
+
+
+
{/* Trailing layout control. Always mounted (just toggled invisible
when inactive) so its -webkit-app-region:no-drag rect is stable —
a freshly-mounted no-drag button inside the drag-region header
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index 9197753b..ba7b364e 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -1,11 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowLeft, ArrowRight } from 'lucide-react'
+import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
+import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ChatHeader } from '@/components/chat-header'
import { ChatEmptyState } from '@/components/chat-empty-state'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
import {
Conversation,
ConversationContent,
@@ -331,6 +338,25 @@ export function ChatSidebar({
if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
+ const activeRunId = activeTabState.runId
+ const handleDownloadChatLog = useCallback(async () => {
+ if (!activeRunId) {
+ toast.error('No chat log available yet')
+ return
+ }
+
+ try {
+ const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
+ if (result.success) {
+ toast.success('Chat log saved')
+ } else if (result.error) {
+ toast.error(result.error)
+ }
+ } catch (err) {
+ console.error('Download chat log failed:', err)
+ toast.error('Failed to download chat log')
+ }
+ }, [activeRunId])
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
@@ -513,6 +539,34 @@ export function ChatSidebar({
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
+
+
+
+
+
+
+
+ Chat options
+
+
+ {
+ void handleDownloadChatLog()
+ }}
+ >
+
+ Download chat log
+
+
+
{onOpenFullScreen && (
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index 230d384c..d0cee9ca 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -290,6 +290,15 @@ const ipcSchemas = {
}),
res: z.object({ success: z.boolean() }),
},
+ 'runs:downloadLog': {
+ req: z.object({
+ runId: z.string().min(1),
+ }),
+ res: z.object({
+ success: z.boolean(),
+ error: z.string().optional(),
+ }),
+ },
'runs:events': {
req: z.null(),
res: z.null(),