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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export {
|
||||
useSessionMessageRecords,
|
||||
useSessionTextMessages,
|
||||
useUserMessageHistory,
|
||||
buildSessionMessageRecordsSnapshot,
|
||||
} from "./sync-context"
|
||||
|
||||
// Sync operations
|
||||
|
||||
@@ -1698,7 +1698,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMessageRecordsSnapshot(
|
||||
export function buildSessionMessageRecordsSnapshot(
|
||||
state: State,
|
||||
sessionID: string,
|
||||
previous?: SessionMessageRecordsSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user