diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 78f8b55e..638af656 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';
@@ -531,6 +532,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 e6c050b3..cd668fa2 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, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
+import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, SquarePen, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@@ -61,6 +61,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 { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
@@ -4662,6 +4668,25 @@ function App() {
return chatViewStateByTab[tabId] ?? emptyChatTabState
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage
+ 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
@@ -4885,6 +4910,35 @@ function App() {
New chat tab
)}
+ {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
+
+
+
+
+
+
+
+ Chat options
+
+
+ {
+ void handleDownloadActiveChatLog()
+ }}
+ >
+
+ Download chat log
+
+
+
+ )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index 06680652..aeb0c167 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -1,9 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
+import { Bug, Maximize2, Minimize2, MoreHorizontal, SquarePen } 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
import {
Conversation,
ConversationContent,
@@ -381,6 +388,25 @@ export function ChatSidebar({
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
+ 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)) {
@@ -585,6 +611,34 @@ export function ChatSidebar({
New chat tab
+
+
+
+
+
+
+
+ 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 37cf41e7..b1f1e80c 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -246,6 +246,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(),