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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user