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(),