From 71eac0d2f65ccacff3aac7e2041ff74316ae79da Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 17 Apr 2026 15:33:26 +0300 Subject: [PATCH] fix: improve session export and empty chat states Move session export into the sidebar menu Add desktop save-and-reveal flow for exported markdown Show empty-state UI instead of a loading skeleton for empty sessions --- packages/desktop/src-tauri/src/main.rs | 40 ++++++++++ .../ui/src/components/chat/ChatContainer.tsx | 36 +-------- .../session/sidebar/SessionNodeItem.tsx | 49 +++++++++++- packages/ui/src/lib/desktop.ts | 26 +++++++ packages/ui/src/lib/exportSession.ts | 78 ++++++++++++++++++- packages/ui/src/sync/index.ts | 1 + packages/ui/src/sync/sync-context.tsx | 2 +- 7 files changed, 192 insertions(+), 40 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 80534a96..598dca91 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -3072,6 +3072,45 @@ fn desktop_read_file(path: String) -> Result { }) } +#[tauri::command] +async fn desktop_save_markdown_file( + app: tauri::AppHandle, + default_file_name: String, + content: String, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let trimmed_file_name = default_file_name.trim(); + if trimmed_file_name.is_empty() { + return Err("Default file name is required".to_string()); + } + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("Markdown", &["md"]) + .set_file_name(trimmed_file_name) + .save_file(move |file_path| { + let _ = tx.send(file_path); + }); + + let Some(file_path) = rx + .await + .map_err(|_| "Save dialog was closed unexpectedly".to_string())? + else { + return Ok(None); + }; + + let path = file_path + .into_path() + .map_err(|_| "Selected export path is not a local filesystem path".to_string())?; + + std::fs::write(&path, content) + .map_err(|error| format!("Failed to save exported session: {error}"))?; + + Ok(Some(path.to_string_lossy().to_string())) +} + #[derive(Serialize)] struct FileContent { mime: String, @@ -3975,6 +4014,7 @@ fn main() { desktop_filter_installed_apps, desktop_get_installed_apps, desktop_fetch_app_icons, + desktop_save_markdown_file, desktop_hosts_get, desktop_hosts_set, desktop_host_probe, diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index de8ceef2..1d06eded 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { RiArrowLeftLine, RiDownloadLine } from '@remixicon/react'; +import { RiArrowLeftLine } from '@remixicon/react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; import { useUIStore } from '@/stores/useUIStore'; -import { toast } from '@/components/ui'; -import { formatSessionAsMarkdown, downloadAsMarkdown, buildExportFilename } from '@/lib/exportSession'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList, { type MessageListHandle } from './MessageList'; @@ -411,23 +409,6 @@ export const ChatContainer: React.FC = () => { const isDesktopExpandedInput = isExpandedInput && !isMobile; const messageListRef = React.useRef(null); - const currentSession = React.useMemo( - () => currentSessionId ? sessions.find((s) => s.id === currentSessionId) ?? null : null, - [currentSessionId, sessions], - ); - - const handleExportMarkdown = React.useCallback(() => { - if (!currentSessionId || sessionMessages.length === 0) { - toast.error('Nothing to export'); - return; - } - const title = currentSession?.title ?? null; - const markdown = formatSessionAsMarkdown(sessionMessages, title); - const filename = buildExportFilename(title); - downloadAsMarkdown(markdown, filename); - toast.success('Session exported'); - }, [currentSessionId, currentSession, sessionMessages]); - const parentSession = React.useMemo(() => { if (!currentSessionId) return null; const current = sessions.find((session) => session.id === currentSessionId); @@ -459,20 +440,6 @@ export const ChatContainer: React.FC = () => { ) : null; - const exportButton = !isMobile && sessionMessages.length > 0 ? ( - - ) : null; - React.useEffect(() => { if (!currentSessionId && !draftOpen) { openNewSessionDraft(); @@ -814,7 +781,6 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > {returnToParentButton} - {exportButton} Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]), ); + const directoryStore = useDirectoryStore(sessionDirectory ?? undefined); + const sync = useSync(); const sessionStatus = useGlobalSessionStatus(session.id); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; @@ -262,6 +268,43 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; + const handleExportSession = React.useCallback(async () => { + if (!sessionDirectory) { + toast.error('Nothing to export'); + return; + } + + await sync.syncSession(session.id); + + const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list; + if (records.length === 0) { + toast.error('Nothing to export'); + return; + } + + const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null); + const filename = buildExportFilename(resolvedSession.title ?? null); + const savedPath = await saveAsMarkdownDesktop(markdown, filename); + + if (savedPath) { + toast.success('Session exported', { + action: { + label: getExportRevealLabel(), + onClick: () => { + void revealExportedMarkdown(savedPath).then((revealed) => { + if (!revealed) { + toast.error('Failed to reveal path'); + } + }); + }, + }, + }); + return; + } + + downloadAsMarkdown(markdown, filename); + toast.success('Session exported'); + }, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync]); if (editingId === session.id) { return ( @@ -432,6 +475,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { )} + { void handleExportSession(); }} className="[&>svg]:mr-1"> + + Export Markdown + {sessionDirectory && !archivedBucket ? (() => { const scopeFolders = getFoldersForScope(sessionDirectory); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 3836281e..62a328e0 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -502,6 +502,32 @@ export const openDesktopPath = async (path: string, app?: string | null): Promis } }; +export const saveDesktopMarkdownFile = async ( + defaultFileName: string, + content: string, +): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return null; + } + + const trimmedFileName = defaultFileName?.trim(); + if (!trimmedFileName) { + return null; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const result = await tauri?.core?.invoke?.('desktop_save_markdown_file', { + defaultFileName: trimmedFileName, + content, + }); + return typeof result === 'string' && result.trim().length > 0 ? result : null; + } catch (error) { + console.warn('Failed to save markdown file (tauri)', error); + return null; + } +}; + export const openDesktopProjectInApp = async ( projectPath: string, appId: string, diff --git a/packages/ui/src/lib/exportSession.ts b/packages/ui/src/lib/exportSession.ts index 89873172..fa4aa263 100644 --- a/packages/ui/src/lib/exportSession.ts +++ b/packages/ui/src/lib/exportSession.ts @@ -1,7 +1,55 @@ import type { Message, Part } from '@opencode-ai/sdk/v2'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { openDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop'; +import { getRevealLabel } from '@/lib/utils'; type SessionMessageRecord = { info: Message; parts: Part[] }; +function formatTimestamp(timestamp: number | undefined): string { + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + return ''; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const monthPart = date.toLocaleString(undefined, { month: 'short' }); + const dayPart = date.getDate(); + const yearPart = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${monthPart} ${dayPart}, ${yearPart}, ${hours}:${minutes}`; +} + +function formatAssistantModel(record: SessionMessageRecord): string { + if (record.info.role === 'user') { + return ''; + } + + const providerID = typeof record.info.providerID === 'string' ? record.info.providerID.trim() : ''; + const modelID = typeof record.info.modelID === 'string' ? record.info.modelID.trim() : ''; + + if (providerID && modelID) { + return `${providerID}/${modelID}`; + } + + return modelID || providerID; +} + +function formatMessageHeader(record: SessionMessageRecord): string { + const label = record.info.role === 'user' ? 'User' : 'Assistant'; + const timestamp = formatTimestamp(record.info.time?.created); + const assistantModel = formatAssistantModel(record); + const details = timestamp && assistantModel + ? `${timestamp} (${assistantModel})` + : (timestamp || assistantModel); + + return details ? `**${label}**\n\n*${details}*` : `**${label}**`; +} + function extractTextFromParts(parts: Part[]): string { return parts .filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string') @@ -10,7 +58,7 @@ function extractTextFromParts(parts: Part[]): string { } function formatMessageAsMarkdown(record: SessionMessageRecord): string { - const role = record.info.role === 'user' ? '### User' : '### Assistant'; + const role = formatMessageHeader(record); const text = extractTextFromParts(record.parts).trim(); if (!text) return ''; @@ -46,13 +94,37 @@ export function downloadAsMarkdown(content: string, filename: string): void { URL.revokeObjectURL(url); } +export async function saveAsMarkdownDesktop(content: string, filename: string): Promise { + return saveDesktopMarkdownFile(filename, content); +} + +export async function revealExportedMarkdown(path: string): Promise { + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; + if (runtimeFiles?.revealPath) { + try { + const result = await runtimeFiles.revealPath(path); + return Boolean(result?.success); + } catch { + return false; + } + } + + return openDesktopPath(path); +} + +export function getExportRevealLabel(): string { + return getRevealLabel(); +} + export function buildExportFilename(sessionTitle?: string | null): string { const base = sessionTitle?.trim() || 'session'; const safe = base + .normalize('NFKC') .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') + .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') .slice(0, 60); + const normalizedBase = safe || 'session'; const date = new Date().toISOString().split('T')[0]; - return `${safe}-${date}.md`; + return `${normalizedBase}-${date}.md`; } diff --git a/packages/ui/src/sync/index.ts b/packages/ui/src/sync/index.ts index a1061a18..36caf3e6 100644 --- a/packages/ui/src/sync/index.ts +++ b/packages/ui/src/sync/index.ts @@ -72,6 +72,7 @@ export { useSessionMessageRecords, useSessionTextMessages, useUserMessageHistory, + buildSessionMessageRecordsSnapshot, } from "./sync-context" // Sync operations diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index c2dbe9bc..5b41cd99 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1698,7 +1698,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous? } } -function buildSessionMessageRecordsSnapshot( +export function buildSessionMessageRecordsSnapshot( state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot,