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)]
|
#[derive(Serialize)]
|
||||||
struct FileContent {
|
struct FileContent {
|
||||||
mime: String,
|
mime: String,
|
||||||
@@ -3975,6 +4014,7 @@ fn main() {
|
|||||||
desktop_filter_installed_apps,
|
desktop_filter_installed_apps,
|
||||||
desktop_get_installed_apps,
|
desktop_get_installed_apps,
|
||||||
desktop_fetch_app_icons,
|
desktop_fetch_app_icons,
|
||||||
|
desktop_save_markdown_file,
|
||||||
desktop_hosts_get,
|
desktop_hosts_get,
|
||||||
desktop_hosts_set,
|
desktop_hosts_set,
|
||||||
desktop_host_probe,
|
desktop_host_probe,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import React from 'react';
|
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 type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { toast } from '@/components/ui';
|
|
||||||
import { formatSessionAsMarkdown, downloadAsMarkdown, buildExportFilename } from '@/lib/exportSession';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
import MessageList, { type MessageListHandle } from './MessageList';
|
import MessageList, { type MessageListHandle } from './MessageList';
|
||||||
@@ -411,23 +409,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
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(() => {
|
const parentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
const current = sessions.find((session) => session.id === currentSessionId);
|
const current = sessions.find((session) => session.id === currentSessionId);
|
||||||
@@ -459,20 +440,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null;
|
) : 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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
@@ -814,7 +781,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
{returnToParentButton}
|
{returnToParentButton}
|
||||||
{exportButton}
|
|
||||||
<ChatViewport
|
<ChatViewport
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiDeleteBinLine,
|
RiDeleteBinLine,
|
||||||
|
RiDownloadLine,
|
||||||
RiErrorWarningLine,
|
RiErrorWarningLine,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFolderLine,
|
RiFolderLine,
|
||||||
@@ -34,7 +35,10 @@ import {
|
|||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
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 { useViewportStore } from '@/sync/viewport-store';
|
||||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||||
@@ -244,6 +248,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const isZombie = useViewportStore(
|
const isZombie = useViewportStore(
|
||||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
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 sessionStatus = useGlobalSessionStatus(session.id);
|
||||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||||||
@@ -262,6 +268,43 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||||||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
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) {
|
if (editingId === session.id) {
|
||||||
return (
|
return (
|
||||||
@@ -432,6 +475,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
|
||||||
|
<RiDownloadLine className="mr-1 h-4 w-4" />
|
||||||
|
Export Markdown
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{sessionDirectory && !archivedBucket ? (() => {
|
{sessionDirectory && !archivedBucket ? (() => {
|
||||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
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 (
|
export const openDesktopProjectInApp = async (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
appId: string,
|
appId: string,
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
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[] };
|
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 {
|
function extractTextFromParts(parts: Part[]): string {
|
||||||
return parts
|
return parts
|
||||||
.filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string')
|
.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 {
|
function formatMessageAsMarkdown(record: SessionMessageRecord): string {
|
||||||
const role = record.info.role === 'user' ? '### User' : '### Assistant';
|
const role = formatMessageHeader(record);
|
||||||
const text = extractTextFromParts(record.parts).trim();
|
const text = extractTextFromParts(record.parts).trim();
|
||||||
|
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -46,13 +94,37 @@ export function downloadAsMarkdown(content: string, filename: string): void {
|
|||||||
URL.revokeObjectURL(url);
|
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 {
|
export function buildExportFilename(sessionTitle?: string | null): string {
|
||||||
const base = sessionTitle?.trim() || 'session';
|
const base = sessionTitle?.trim() || 'session';
|
||||||
const safe = base
|
const safe = base
|
||||||
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
.slice(0, 60);
|
.slice(0, 60);
|
||||||
|
const normalizedBase = safe || 'session';
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
return `${safe}-${date}.md`;
|
return `${normalizedBase}-${date}.md`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export {
|
|||||||
useSessionMessageRecords,
|
useSessionMessageRecords,
|
||||||
useSessionTextMessages,
|
useSessionTextMessages,
|
||||||
useUserMessageHistory,
|
useUserMessageHistory,
|
||||||
|
buildSessionMessageRecordsSnapshot,
|
||||||
} from "./sync-context"
|
} from "./sync-context"
|
||||||
|
|
||||||
// Sync operations
|
// Sync operations
|
||||||
|
|||||||
@@ -1698,7 +1698,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionMessageRecordsSnapshot(
|
export function buildSessionMessageRecordsSnapshot(
|
||||||
state: State,
|
state: State,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
previous?: SessionMessageRecordsSnapshot,
|
previous?: SessionMessageRecordsSnapshot,
|
||||||
|
|||||||
Reference in New Issue
Block a user