feat(ui): expose desktop session actions in header

Add a dedicated desktop header menu for the active session while keeping recent-session switching available when the sidebar is closed. Match inline rename behavior with the sidebar and expose rename, copy ID, share, export, archive, and delete actions with localized feedback.

Automatically copy newly created share links, keep share/unshare state synchronized across live and global stores, and normalize stale upstream unshare responses so the UI immediately reflects successful unsharing.

Require Markdown exports to load every available message page before formatting the conversation. Abort incomplete root exports, retain explicit child-session skip warnings, and guard complete-history pagination against failures and cursor cycles.
This commit is contained in:
Bohdan Triapitsyn
2026-07-31 21:02:23 +03:00
parent aae889b904
commit a6fb7193dc
20 changed files with 564 additions and 72 deletions
+289 -33
View File
@@ -21,7 +21,8 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { useSessionMessagesResolved } from '@/sync/sync-context';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useGitBranchLabel } from '@/stores/useGitStore';
@@ -76,6 +77,11 @@ import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { useShallow } from 'zustand/react/shallow';
import type { IconName } from "@/components/icon/icons";
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
const MOBILE_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors';
@@ -708,6 +714,7 @@ type HeaderSessionSnapshot = {
directory: string | null;
created: number | null;
slug: string | null;
shareUrl: string | null;
};
export const Header: React.FC<HeaderProps> = ({
@@ -748,6 +755,7 @@ export const Header: React.FC<HeaderProps> = ({
directory: record.directory ?? null,
created: session.time?.created ?? null,
slug: record.slug ?? null,
shareUrl: session.share?.url ?? null,
};
},
[currentSessionId],
@@ -1300,6 +1308,146 @@ export const Header: React.FC<HeaderProps> = ({
const trimmedTitle = currentSession?.title?.trim();
return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session';
}, [activeProjectLabel, currentSession?.title, currentSessionId]);
const headerDirectoryStore = useDirectoryStore(openDirectory || undefined, { bootstrap: false });
const sync = useSync();
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
const shareSession = useSessionUIStore((state) => state.shareSession);
const unshareSession = useSessionUIStore((state) => state.unshareSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const [isRenamingHeaderSession, setIsRenamingHeaderSession] = React.useState(false);
const [isHeaderSessionMenuOpen, setIsHeaderSessionMenuOpen] = React.useState(false);
const pendingHeaderRenameRef = React.useRef(false);
const [headerSessionTitleDraft, setHeaderSessionTitleDraft] = React.useState('');
const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<'archive' | 'delete' | null>(null);
const headerRenameFormRef = React.useRef<HTMLFormElement | null>(null);
React.useEffect(() => {
pendingHeaderRenameRef.current = false;
setIsHeaderSessionMenuOpen(false);
setIsRenamingHeaderSession(false);
setHeaderSessionTitleDraft('');
setPendingHeaderRetentionAction(null);
}, [currentSessionId]);
const beginHeaderSessionRename = React.useCallback(() => {
if (!currentSessionId) return;
setHeaderSessionTitleDraft(currentSession?.title?.trim() || currentSessionTitle);
setIsRenamingHeaderSession(true);
}, [currentSession?.title, currentSessionId, currentSessionTitle]);
const saveHeaderSessionRename = React.useCallback(async () => {
if (!currentSessionId) return;
const title = headerSessionTitleDraft.trim();
if (title && title !== currentSession?.title?.trim()) {
await updateSessionTitle(currentSessionId, title);
}
setIsRenamingHeaderSession(false);
}, [currentSession?.title, currentSessionId, headerSessionTitleDraft, updateSessionTitle]);
React.useEffect(() => {
if (!isRenamingHeaderSession) return;
const handleDocumentMouseDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target || !headerRenameFormRef.current?.contains(target)) {
void saveHeaderSessionRename();
}
};
document.addEventListener('mousedown', handleDocumentMouseDown);
return () => document.removeEventListener('mousedown', handleDocumentMouseDown);
}, [isRenamingHeaderSession, saveHeaderSessionRename]);
const copyCurrentSessionId = React.useCallback(() => {
if (!currentSessionId) return;
void copyTextToClipboard(currentSessionId).then((result) => {
toast[result.ok ? 'success' : 'error'](t(result.ok
? 'sessions.sidebar.session.copyId.success'
: 'sessions.sidebar.session.copyId.error'));
}).catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
}, [currentSessionId, t]);
const shareCurrentSession = React.useCallback(async () => {
if (!currentSessionId) return;
const result = await shareSession(currentSessionId);
if (result?.share?.url) {
const copied = await copyTextToClipboard(result.share.url);
toast[copied.ok ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), {
description: t(copied.ok
? 'sessions.sidebar.session.share.successDescription'
: 'sessions.sidebar.session.share.copyUrlError'),
});
return;
}
toast.error(t('sessions.sidebar.session.share.error'));
}, [currentSessionId, shareSession, t]);
const copyCurrentSessionShareUrl = React.useCallback(() => {
const shareUrl = currentSession?.shareUrl;
if (!shareUrl) return;
void copyTextToClipboard(shareUrl).then((result) => {
toast[result.ok ? 'success' : 'error'](t(result.ok
? 'sessions.sidebar.session.menu.copied'
: 'sessions.sidebar.session.share.copyUrlError'));
}).catch(() => toast.error(t('sessions.sidebar.session.share.copyUrlError')));
}, [currentSession?.shareUrl, t]);
const unshareCurrentSession = React.useCallback(async () => {
if (!currentSessionId) return;
const result = await unshareSession(currentSessionId);
toast[result ? 'success' : 'error'](t(result
? 'sessions.sidebar.session.unshare.success'
: 'sessions.sidebar.session.unshare.error'));
}, [currentSessionId, t, unshareSession]);
const exportCurrentSession = React.useCallback(async () => {
if (!currentSessionId || !openDirectory) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
try {
await sync.loadCompleteHistory(currentSessionId, openDirectory);
} catch {
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
return;
}
const records = buildSessionMessageRecordsSnapshot(headerDirectoryStore.getState(), currentSessionId).list;
if (records.length === 0) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
const markdown = formatSessionAsMarkdown(records, currentSession?.title ?? null);
const filename = buildExportFilename(currentSession?.title ?? null);
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
if (!savedPath) downloadAsMarkdown(markdown, filename);
toast.success(t('sessions.sidebar.session.export.success'));
}, [currentSession?.title, currentSessionId, headerDirectoryStore, openDirectory, sync, t]);
const confirmHeaderRetentionAction = React.useCallback(async () => {
if (!currentSessionId || !pendingHeaderRetentionAction) return;
const sessions = useGlobalSessionsStore.getState().activeSessions;
const ids = [currentSessionId];
for (let index = 0; index < ids.length; index += 1) {
const parentId = ids[index];
for (const session of sessions) {
if ((session as typeof session & { parentID?: string | null }).parentID === parentId && !ids.includes(session.id)) {
ids.push(session.id);
}
}
}
const action = pendingHeaderRetentionAction;
setPendingHeaderRetentionAction(null);
const result = action === 'archive' ? await archiveSessions(ids) : await deleteSessions(ids);
const failedIds = result.failedIds;
if (failedIds.length > 0) {
toast.error(t(action === 'archive'
? 'sessions.sidebar.session.archive.error'
: 'sessions.sidebar.session.delete.error'));
return;
}
toast.success(t(action === 'archive'
? 'sessions.sidebar.session.archive.success'
: 'sessions.sidebar.session.delete.success'));
}, [archiveSessions, currentSessionId, deleteSessions, pendingHeaderRetentionAction, t]);
// Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the
// chat area; while one is open the header shows the surface identity
@@ -2068,8 +2216,8 @@ export const Header: React.FC<HeaderProps> = ({
style={{ width: headerControlsSpacerWidth }}
/>
{/* Sidebar toggle + project actions live in the persistent
TitlebarLeftControls overlay; the header reserves matching left space
via padding (see headerStyle) when the sidebar is collapsed. */}
TitlebarLeftControls overlay; the spacers above reserve its footprint
while the sidebar is closed. */}
<div className="flex min-w-0 flex-1 items-center">
{activeSurfaceHeader ? (
<div className="mr-3 flex min-w-0 flex-col items-start px-1 py-0.5 -my-0.5 text-left">
@@ -2083,37 +2231,123 @@ export const Header: React.FC<HeaderProps> = ({
) : null}
</div>
) : (
<SessionSwitcherDropdown>
<button
type="button"
aria-label={t('sessions.switcher.openAria')}
className="app-region-no-drag mr-3 flex min-w-0 flex-col items-start rounded-md px-1 py-0.5 -my-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
>
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
{currentBranchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
<span className="truncate">{currentBranchLabel}</span>
</span>
) : null}
{!isNewSessionDraftOpen && worktreeBadgeKind ? (
<span className={cn(
"inline-flex min-w-0 items-center gap-0.5",
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
)}>
<Icon name="alert" className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{worktreeBadge}</span>
</span>
) : null}
</span>
<div className="app-region-no-drag mr-3 flex min-w-0 max-w-full items-center gap-0.5 py-0.5 -my-0.5 text-left">
{!isSidebarOpen ? (
<SessionSwitcherDropdown align="start">
<button
type="button"
className={desktopHeaderIconButtonClass}
aria-label={t('sessions.switcher.openAria')}
>
<Icon name="history" className="h-[18px] w-[18px]" />
</button>
</SessionSwitcherDropdown>
) : null}
</button>
</SessionSwitcherDropdown>
<div className="flex min-w-0 flex-col justify-center px-1">
{isRenamingHeaderSession ? (
<form
ref={headerRenameFormRef}
className="flex w-full min-w-0 items-center gap-2 leading-tight"
onSubmit={(event) => {
event.preventDefault();
void saveHeaderSessionRename();
}}
>
<input
value={headerSessionTitleDraft}
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
setIsRenamingHeaderSession(false);
}
}}
placeholder={t('sessions.sidebar.session.menu.rename')}
className="min-w-0 flex-1 bg-transparent typography-ui-label text-[14px] font-normal leading-tight outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={t('sessions.sidebar.session.rename.save')}
title={t('sessions.sidebar.session.rename.save')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="check" className="size-4" />
</button>
<button
type="button"
onClick={() => setIsRenamingHeaderSession(false)}
aria-label={t('sessions.sidebar.session.rename.cancel')}
title={t('sessions.sidebar.session.rename.cancel')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</form>
) : (
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
)}
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
{currentBranchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
<span className="truncate">{currentBranchLabel}</span>
</span>
) : null}
{!isNewSessionDraftOpen && worktreeBadgeKind ? (
<span className={cn(
"inline-flex min-w-0 items-center gap-0.5",
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
)}>
<Icon name="alert" className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{worktreeBadge}</span>
</span>
) : null}
</span>
) : null}
</div>
<div className="flex h-[18px] shrink-0 items-center justify-center self-start">
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? (
<DropdownMenu
open={isHeaderSessionMenuOpen}
onOpenChange={setIsHeaderSessionMenuOpen}
onOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false;
beginHeaderSessionRename();
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="h-[18px] w-6 px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label={t('header.sessionActions.openAria')}>
<Icon name="more" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem onClick={() => { pendingHeaderRenameRef.current = true; }}><Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}</DropdownMenuItem>
<DropdownMenuItem onClick={copyCurrentSessionId}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}</DropdownMenuItem>
<DropdownMenuSeparator />
{currentSession?.shareUrl ? (
<>
<DropdownMenuItem onClick={copyCurrentSessionShareUrl}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => void unshareCurrentSession()}><Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPendingHeaderRetentionAction('archive')}><Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction('delete')}><Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
)}
{tabs.length > 0 && (
@@ -2595,6 +2829,28 @@ export const Header: React.FC<HeaderProps> = ({
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
<Dialog open={pendingHeaderRetentionAction !== null} onOpenChange={(open) => { if (!open) setPendingHeaderRetentionAction(null); }}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>{pendingHeaderRetentionAction === 'delete'
? t('sessions.sidebar.dialogs.deleteSession.title')
: t('sessions.sidebar.dialogs.archiveSession.title')}</DialogTitle>
<DialogDescription>{pendingHeaderRetentionAction === 'delete'
? t('sessions.sidebar.dialogs.deleteSession.single', { sessionTitle: currentSessionTitle })
: t('sessions.sidebar.dialogs.archiveSession.single', { sessionTitle: currentSessionTitle })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPendingHeaderRetentionAction(null)}>
{t('sessions.sidebar.dialogs.cancel')}
</Button>
<Button variant="destructive" size="sm" onClick={() => void confirmHeaderRetentionAction()}>
{pendingHeaderRetentionAction === 'delete'
? t('sessions.sidebar.bulkActions.delete')
: t('sessions.sidebar.bulkActions.archive')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UpdateDialog
open={remoteUpdateDialogOpen}
onOpenChange={setRemoteUpdateDialogOpen}
@@ -831,6 +831,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleCancelEdit,
handleShareSession,
handleCopyShareUrl,
handleCopySessionId,
handleUnshareSession,
handleDeleteSession,
confirmDeleteSession,
@@ -907,6 +908,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
@@ -1560,6 +1562,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleShareSession={stableHandleShareSession}
copiedSessionId={copiedSessionId}
handleCopyShareUrl={stableHandleCopyShareUrl}
handleCopySessionId={stableHandleCopySessionId}
handleUnshareSession={stableHandleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
@@ -80,6 +80,7 @@ type Props = {
handleShareSession: (session: Session) => void;
copiedSessionId: string | null;
handleCopyShareUrl: (url: string, sessionId: string) => void;
handleCopySessionId: (sessionId: string) => void;
handleUnshareSession: (sessionId: string) => void;
openSidebarMenuKey: string | null;
setOpenSidebarMenuKey: (key: string | null) => void;
@@ -274,6 +275,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
handleShareSession,
copiedSessionId,
handleCopyShareUrl,
handleCopySessionId,
handleUnshareSession,
openSidebarMenuKey,
setOpenSidebarMenuKey,
@@ -478,7 +480,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
let skipped = 0;
for (const child of children) {
try {
await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined);
if (!sessionDirectory) throw new Error('Session directory is required for export');
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -510,7 +513,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return;
}
await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
try {
await sync.loadCompleteHistory(session.id, sessionDirectory);
} catch {
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
return;
}
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
@@ -900,6 +908,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</Item>
<Item onClick={() => handleCopySessionId(session.id)} className="[&>svg]:mr-1">
<Icon name="file-copy" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.copyId')}
</Item>
<Item onClick={() => sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1">
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
@@ -1585,6 +1597,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
&& prev.togglePinnedSession === next.togglePinnedSession
&& prev.handleShareSession === next.handleShareSession
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
&& prev.handleCopySessionId === next.handleCopySessionId
&& prev.handleUnshareSession === next.handleUnshareSession
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
&& prev.getFoldersForScope === next.getFoldersForScope
@@ -117,36 +117,52 @@ export const useSessionActions = (args: Args) => {
args.setEditTitle('');
}, [args]);
const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise<boolean> => {
try {
const result = await copyTextToClipboard(url);
if (!result.ok) return false;
setCopiedSessionId(sessionId);
if (copyTimeout.current) clearTimeout(copyTimeout.current);
copyTimeout.current = window.setTimeout(() => {
setCopiedSessionId(null);
copyTimeout.current = null;
}, 2000);
return true;
} catch {
return false;
}
}, []);
const handleShareSession = React.useCallback(async (session: Session) => {
const result = await args.shareSession(session.id);
if (result && result.share?.url) {
toast.success(t('sessions.sidebar.session.share.successTitle'), {
description: t('sessions.sidebar.session.share.successDescription'),
});
} else {
if (!result?.share?.url) {
toast.error(t('sessions.sidebar.session.share.error'));
return;
}
}, [args, t]);
const copied = await copyShareUrl(result.share.url, session.id);
toast[copied ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), {
description: t(copied
? 'sessions.sidebar.session.share.successDescription'
: 'sessions.sidebar.session.share.copyUrlError'),
});
}, [args, copyShareUrl, t]);
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
void copyTextToClipboard(url)
void copyShareUrl(url, sessionId).then((copied) => {
if (!copied) toast.error(t('sessions.sidebar.session.share.copyUrlError'));
});
}, [copyShareUrl, t]);
const handleCopySessionId = React.useCallback((sessionId: string) => {
void copyTextToClipboard(sessionId)
.then((result) => {
if (!result.ok) {
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
if (result.ok) {
toast.success(t('sessions.sidebar.session.copyId.success'));
return;
}
setCopiedSessionId(sessionId);
if (copyTimeout.current) {
clearTimeout(copyTimeout.current);
}
copyTimeout.current = window.setTimeout(() => {
setCopiedSessionId(null);
copyTimeout.current = null;
}, 2000);
toast.error(t('sessions.sidebar.session.copyId.error'));
})
.catch(() => {
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
});
.catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
}, [t]);
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
@@ -278,6 +294,7 @@ export const useSessionActions = (args: Args) => {
handleCancelEdit,
handleShareSession,
handleCopyShareUrl,
handleCopySessionId,
handleUnshareSession,
handleDeleteSession,
confirmDeleteSession,