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
This commit is contained in:
Bohdan Triapitsyn
2026-04-17 15:33:26 +03:00
parent a17d159e18
commit 71eac0d2f6
7 changed files with 192 additions and 40 deletions
+40
View File
@@ -3072,6 +3072,45 @@ fn desktop_read_file(path: String) -> Result<FileContent, String> {
})
}
#[tauri::command]
async fn desktop_save_markdown_file(
app: tauri::AppHandle,
default_file_name: String,
content: String,
) -> Result<Option<String>, 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,
@@ -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<MessageListHandle | null>(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 = () => {
</Button>
) : null;
const exportButton = !isMobile && sessionMessages.length > 0 ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleExportMarkdown}
className="absolute right-3 top-3 z-20 !font-normal text-muted-foreground hover:text-foreground"
aria-label="Export session as Markdown"
title="Export session as Markdown"
>
<RiDownloadLine className="h-4 w-4" />
</Button>
) : 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}
<ChatViewport
currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
@@ -20,6 +20,7 @@ import {
RiCheckLine,
RiCloseLine,
RiDeleteBinLine,
RiDownloadLine,
RiErrorWarningLine,
RiFileCopyLine,
RiFolderLine,
@@ -34,7 +35,10 @@ import {
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { toast } from '@/components/ui';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabel, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
@@ -244,6 +248,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const isZombie = useViewportStore(
React.useCallback((state) => 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 {
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<RiDownloadLine className="mr-1 h-4 w-4" />
Export Markdown
</DropdownMenuItem>
{sessionDirectory && !archivedBucket ? (() => {
const scopeFolders = getFoldersForScope(sessionDirectory);
+26
View File
@@ -502,6 +502,32 @@ export const openDesktopPath = async (path: string, app?: string | null): Promis
}
};
export const saveDesktopMarkdownFile = async (
defaultFileName: string,
content: string,
): Promise<string | null> => {
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,
+75 -3
View File
@@ -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<string | null> {
return saveDesktopMarkdownFile(filename, content);
}
export async function revealExportedMarkdown(path: string): Promise<boolean> {
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`;
}
+1
View File
@@ -72,6 +72,7 @@ export {
useSessionMessageRecords,
useSessionTextMessages,
useUserMessageHistory,
buildSessionMessageRecordsSnapshot,
} from "./sync-context"
// Sync operations
+1 -1
View File
@@ -1698,7 +1698,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
}
}
function buildSessionMessageRecordsSnapshot(
export function buildSessionMessageRecordsSnapshot(
state: State,
sessionID: string,
previous?: SessionMessageRecordsSnapshot,