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:
@@ -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,
|
||||
|
||||
@@ -475,6 +475,10 @@ export const dict = {
|
||||
'sessions.sidebar.project.actions.closeProject': 'Close project',
|
||||
'sessions.sidebar.project.actions.newDraftSession': 'New draft session',
|
||||
'sessions.sidebar.session.menu.rename': 'Rename',
|
||||
'sessions.sidebar.session.menu.copyId': 'Copy session ID',
|
||||
'sessions.sidebar.session.copyId.success': 'Session ID copied',
|
||||
'sessions.sidebar.session.copyId.error': 'Failed to copy session ID',
|
||||
'header.sessionActions.openAria': 'Open session actions',
|
||||
'sessions.sidebar.session.rename.save': 'Save session name',
|
||||
'sessions.sidebar.session.rename.cancel': 'Cancel renaming session',
|
||||
'sessions.sidebar.session.menu.unpin': 'Unpin session',
|
||||
@@ -497,6 +501,7 @@ export const dict = {
|
||||
'sessions.sidebar.session.menu.label': 'Session menu',
|
||||
'sessions.sidebar.session.untitled': 'Untitled Session',
|
||||
'sessions.sidebar.session.export.nothingToExport': 'Nothing to export',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': 'Failed to load the complete session history',
|
||||
'sessions.sidebar.session.export.success': 'Session exported',
|
||||
'sessions.sidebar.session.export.failedRevealPath': 'Failed to reveal path',
|
||||
'sessions.sidebar.session.export.untitledSubagent': 'Untitled Sub-agent',
|
||||
@@ -552,7 +557,7 @@ export const dict = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'This action permanently removes {count} sessions from {dateLabel}.',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count} more',
|
||||
'sessions.sidebar.session.share.successTitle': 'Session shared',
|
||||
'sessions.sidebar.session.share.successDescription': 'You can copy the link from the menu.',
|
||||
'sessions.sidebar.session.share.successDescription': 'Share link copied to clipboard.',
|
||||
'sessions.sidebar.session.share.error': 'Unable to share session',
|
||||
'sessions.sidebar.session.share.copyUrlError': 'Failed to copy URL',
|
||||
'sessions.sidebar.session.unshare.success': 'Session unshared',
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.project.actions.edit": "Editar",
|
||||
"sessions.sidebar.project.actions.newDraftSession": "Nueva sesión de borrador",
|
||||
"sessions.sidebar.session.menu.rename": "Cambiar nombre",
|
||||
"sessions.sidebar.session.menu.copyId": "Copiar ID de sesión",
|
||||
"sessions.sidebar.session.copyId.success": "ID de sesión copiado",
|
||||
"sessions.sidebar.session.copyId.error": "No se pudo copiar el ID de sesión",
|
||||
"header.sessionActions.openAria": "Abrir acciones de sesión",
|
||||
"sessions.sidebar.session.rename.save": "Guardar nombre de sesión",
|
||||
"sessions.sidebar.session.rename.cancel": "Cancelar cambio de nombre de sesión",
|
||||
"sessions.sidebar.session.menu.unpin": "Desanclar sesión",
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.label": "Menú de sesión",
|
||||
"sessions.sidebar.session.untitled": "Sesión sin título",
|
||||
"sessions.sidebar.session.export.nothingToExport": "No hay nada para exportar",
|
||||
"sessions.sidebar.session.export.failedLoadHistory": "No se pudo cargar el historial completo de la sesión",
|
||||
"sessions.sidebar.session.export.success": "Sesión exportada",
|
||||
"sessions.sidebar.session.export.failedRevealPath": "No se pudo mostrar la ruta",
|
||||
"sessions.sidebar.session.export.untitledSubagent": "Subagente sin título",
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate": "Esta acción elimina permanentemente {count} sesiones de {dateLabel}.",
|
||||
"sessions.sidebar.dialogs.sessionList.more": "+{count} más",
|
||||
"sessions.sidebar.session.share.successTitle": "Sesión compartida",
|
||||
"sessions.sidebar.session.share.successDescription": "Puedes copiar el enlace desde el menú.",
|
||||
"sessions.sidebar.session.share.successDescription": "El enlace para compartir se copió al portapapeles.",
|
||||
"sessions.sidebar.session.share.error": "No se puede compartir la sesión",
|
||||
"sessions.sidebar.session.share.copyUrlError": "No se pudo copiar la URL",
|
||||
"sessions.sidebar.session.unshare.success": "Sesión dejó de compartirse",
|
||||
|
||||
@@ -317,6 +317,10 @@ export const dict = {
|
||||
'sessions.sidebar.project.actions.edit': 'Modifier',
|
||||
'sessions.sidebar.project.actions.newDraftSession': 'Nouveau brouillon de session',
|
||||
'sessions.sidebar.session.menu.rename': 'Rebaptiser',
|
||||
'sessions.sidebar.session.menu.copyId': 'Copier l’ID de session',
|
||||
'sessions.sidebar.session.copyId.success': 'ID de session copié',
|
||||
'sessions.sidebar.session.copyId.error': 'Impossible de copier l’ID de session',
|
||||
'header.sessionActions.openAria': 'Ouvrir les actions de session',
|
||||
'sessions.sidebar.session.rename.save': 'Enregistrer le nom de la session',
|
||||
'sessions.sidebar.session.rename.cancel': 'Annuler la session de changement de nom',
|
||||
'sessions.sidebar.session.menu.unpin': 'Désépingler la session',
|
||||
@@ -339,6 +343,7 @@ export const dict = {
|
||||
'sessions.sidebar.session.menu.label': 'Menu des sessions',
|
||||
'sessions.sidebar.session.untitled': 'Session sans titre',
|
||||
'sessions.sidebar.session.export.nothingToExport': 'Rien à exporter',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': 'Impossible de charger l’historique complet de la session',
|
||||
'sessions.sidebar.session.export.success': 'Session exportée',
|
||||
'sessions.sidebar.session.export.failedRevealPath': 'Impossible de révéler le chemin',
|
||||
'sessions.sidebar.session.export.untitledSubagent': 'Sous-agent sans titre',
|
||||
@@ -394,7 +399,7 @@ export const dict = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'Cette action supprime définitivement les sessions {count} de {dateLabel}.',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count} plus',
|
||||
'sessions.sidebar.session.share.successTitle': 'Session partagée',
|
||||
'sessions.sidebar.session.share.successDescription': 'Vous pouvez copier le lien depuis le menu.',
|
||||
'sessions.sidebar.session.share.successDescription': 'Le lien de partage a été copié dans le presse-papiers.',
|
||||
'sessions.sidebar.session.share.error': 'Impossible de partager la session',
|
||||
'sessions.sidebar.session.share.copyUrlError': 'Échec de la copie de URL',
|
||||
'sessions.sidebar.session.unshare.success': 'Session non partagée',
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.project.actions.edit': '編集',
|
||||
'sessions.sidebar.project.actions.newDraftSession': '新しい下書きセッション',
|
||||
'sessions.sidebar.session.menu.rename': '名前の変更',
|
||||
'sessions.sidebar.session.menu.copyId': 'セッションIDをコピー',
|
||||
'sessions.sidebar.session.copyId.success': 'セッションIDをコピーしました',
|
||||
'sessions.sidebar.session.copyId.error': 'セッションIDをコピーできませんでした',
|
||||
'header.sessionActions.openAria': 'セッション操作を開く',
|
||||
'sessions.sidebar.session.rename.save': 'セッション名を保存',
|
||||
'sessions.sidebar.session.rename.cancel': 'セッション名の変更をキャンセル',
|
||||
'sessions.sidebar.session.menu.unpin': 'セッションのピン留めを解除',
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.label': 'セッションメニュー',
|
||||
'sessions.sidebar.session.untitled': '無題のセッション',
|
||||
'sessions.sidebar.session.export.nothingToExport': 'エクスポートするものがありません',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': 'セッション履歴全体を読み込めませんでした',
|
||||
'sessions.sidebar.session.export.success': 'セッションをエクスポートしました',
|
||||
'sessions.sidebar.session.export.failedRevealPath': 'パスの表示に失敗しました',
|
||||
'sessions.sidebar.session.export.untitledSubagent': '無題のサブエージェント',
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'この操作は{dateLabel}の{count}セッションを完全に削除します。',
|
||||
'sessions.sidebar.dialogs.sessionList.more': 'さらに{count}件',
|
||||
'sessions.sidebar.session.share.successTitle': 'セッションを共有しました',
|
||||
'sessions.sidebar.session.share.successDescription': 'メニューからリンクをコピーできます。',
|
||||
'sessions.sidebar.session.share.successDescription': '共有リンクをクリップボードにコピーしました。',
|
||||
'sessions.sidebar.session.share.error': 'セッションを共有できません',
|
||||
'sessions.sidebar.session.share.copyUrlError': 'URLのコピーに失敗しました',
|
||||
'sessions.sidebar.session.unshare.success': 'セッションの共有を解除しました',
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.project.actions.edit': '편집',
|
||||
'sessions.sidebar.project.actions.newDraftSession': '새 드래프트 세션',
|
||||
'sessions.sidebar.session.menu.rename': '이름 변경',
|
||||
'sessions.sidebar.session.menu.copyId': '세션 ID 복사',
|
||||
'sessions.sidebar.session.copyId.success': '세션 ID가 복사되었습니다',
|
||||
'sessions.sidebar.session.copyId.error': '세션 ID를 복사하지 못했습니다',
|
||||
'header.sessionActions.openAria': '세션 작업 열기',
|
||||
'sessions.sidebar.session.rename.save': '세션 이름 저장',
|
||||
'sessions.sidebar.session.rename.cancel': '세션 이름 변경 취소',
|
||||
'sessions.sidebar.session.menu.unpin': '세션 고정 해제',
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.label': '세션 메뉴',
|
||||
'sessions.sidebar.session.untitled': '제목 없는 세션',
|
||||
'sessions.sidebar.session.export.nothingToExport': '내보낼 내용 없음',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': '전체 세션 기록을 불러오지 못했습니다',
|
||||
'sessions.sidebar.session.export.success': '세션 내보냄',
|
||||
'sessions.sidebar.session.export.failedRevealPath': '경로 표시 실패',
|
||||
'sessions.sidebar.session.export.untitledSubagent': '제목 없는 서브 에이전트',
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': '이 작업은 {dateLabel}의 세션 {count}개를 영구 삭제합니다.',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count}개 더 보기',
|
||||
'sessions.sidebar.session.share.successTitle': '세션 공유됨',
|
||||
'sessions.sidebar.session.share.successDescription': '메뉴에서 링크를 복사할 수 있습니다.',
|
||||
'sessions.sidebar.session.share.successDescription': '공유 링크가 클립보드에 복사되었습니다.',
|
||||
'sessions.sidebar.session.share.error': '세션을 공유할 수 없음',
|
||||
'sessions.sidebar.session.share.copyUrlError': 'URL 복사 실패',
|
||||
'sessions.sidebar.session.unshare.success': '세션 공유 해제됨',
|
||||
|
||||
@@ -279,6 +279,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.project.actions.edit': 'Edytuj',
|
||||
'sessions.sidebar.project.actions.newDraftSession': 'Nowa sesja robocza',
|
||||
'sessions.sidebar.session.menu.rename': 'Zmień nazwę',
|
||||
'sessions.sidebar.session.menu.copyId': 'Kopiuj ID sesji',
|
||||
'sessions.sidebar.session.copyId.success': 'Skopiowano ID sesji',
|
||||
'sessions.sidebar.session.copyId.error': 'Nie udało się skopiować ID sesji',
|
||||
'header.sessionActions.openAria': 'Otwórz działania sesji',
|
||||
'sessions.sidebar.session.rename.save': 'Zapisz nazwę sesji',
|
||||
'sessions.sidebar.session.rename.cancel': 'Anuluj zmianę nazwy sesji',
|
||||
'sessions.sidebar.session.menu.unpin': 'Odepnij sesję',
|
||||
@@ -301,6 +305,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.label': 'Menu sesji',
|
||||
'sessions.sidebar.session.untitled': 'Nienazwana Sesja',
|
||||
'sessions.sidebar.session.export.nothingToExport': 'Brak danych do eksportu',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': 'Nie udało się wczytać pełnej historii sesji',
|
||||
'sessions.sidebar.session.export.success': 'Sesja wyeksportowana',
|
||||
'sessions.sidebar.session.export.failedRevealPath': 'Nie udało się ujawnić ścieżki',
|
||||
'sessions.sidebar.session.export.untitledSubagent': 'Nienazwane Pod-agent',
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'Ta akcja trwale usuwa {count} sesji z {dateLabel}.',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count} więcej',
|
||||
'sessions.sidebar.session.share.successTitle': 'Sesja udostępniona',
|
||||
'sessions.sidebar.session.share.successDescription': 'Możesz skopiować link z menu.',
|
||||
'sessions.sidebar.session.share.successDescription': 'Link udostępniania skopiowano do schowka.',
|
||||
'sessions.sidebar.session.share.error': 'Nie można udostępnić sesji',
|
||||
'sessions.sidebar.session.share.copyUrlError': 'Nie udało się skopiować URL',
|
||||
'sessions.sidebar.session.unshare.success': 'Cofnięto udostępnienie sesji',
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.project.actions.edit": "Editar",
|
||||
"sessions.sidebar.project.actions.newDraftSession": "Nova sessão de rascunho",
|
||||
"sessions.sidebar.session.menu.rename": "Renomear",
|
||||
"sessions.sidebar.session.menu.copyId": "Copiar ID da sessão",
|
||||
"sessions.sidebar.session.copyId.success": "ID da sessão copiado",
|
||||
"sessions.sidebar.session.copyId.error": "Não foi possível copiar o ID da sessão",
|
||||
"header.sessionActions.openAria": "Abrir ações da sessão",
|
||||
"sessions.sidebar.session.rename.save": "Salvar nome da sessão",
|
||||
"sessions.sidebar.session.rename.cancel": "Cancelar renomeação da sessão",
|
||||
"sessions.sidebar.session.menu.unpin": "Desfixar sessão",
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.label": "Menu da sessão",
|
||||
"sessions.sidebar.session.untitled": "Sessão sem título",
|
||||
"sessions.sidebar.session.export.nothingToExport": "Não há nada para exportar",
|
||||
"sessions.sidebar.session.export.failedLoadHistory": "Não foi possível carregar o histórico completo da sessão",
|
||||
"sessions.sidebar.session.export.success": "Sessão exportada",
|
||||
"sessions.sidebar.session.export.failedRevealPath": "Não foi possível mostrar o caminho",
|
||||
"sessions.sidebar.session.export.untitledSubagent": "Subagente sem título",
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate": "Esta ação remove permanentemente {count} sessões de {dateLabel}.",
|
||||
"sessions.sidebar.dialogs.sessionList.more": "+{count} mais",
|
||||
"sessions.sidebar.session.share.successTitle": "Sessão compartida",
|
||||
"sessions.sidebar.session.share.successDescription": "Você pode copiar o link pelo menu.",
|
||||
"sessions.sidebar.session.share.successDescription": "O link de compartilhamento foi copiado para a área de transferência.",
|
||||
"sessions.sidebar.session.share.error": "Não é possível compartilhar a sessão",
|
||||
"sessions.sidebar.session.share.copyUrlError": "Não foi possível copiar a URL",
|
||||
"sessions.sidebar.session.unshare.success": "Sessão dejó de compartilharse",
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.project.actions.edit": "Редагувати",
|
||||
"sessions.sidebar.project.actions.newDraftSession": "Нова чернетка сесії",
|
||||
"sessions.sidebar.session.menu.rename": "Перейменувати",
|
||||
"sessions.sidebar.session.menu.copyId": "Копіювати ID сесії",
|
||||
"sessions.sidebar.session.copyId.success": "ID сесії скопійовано",
|
||||
"sessions.sidebar.session.copyId.error": "Не вдалося скопіювати ID сесії",
|
||||
"header.sessionActions.openAria": "Відкрити дії із сесією",
|
||||
"sessions.sidebar.session.rename.save": "Зберегти назву сесії",
|
||||
"sessions.sidebar.session.rename.cancel": "Скасувати перейменування сесії",
|
||||
"sessions.sidebar.session.menu.unpin": "Відкріпити сесія",
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.label": "Меню сесії",
|
||||
"sessions.sidebar.session.untitled": "Сесія без назви",
|
||||
"sessions.sidebar.session.export.nothingToExport": "Нічого для експорту",
|
||||
"sessions.sidebar.session.export.failedLoadHistory": "Не вдалося завантажити повну історію сесії",
|
||||
"sessions.sidebar.session.export.success": "Сесія експортовано",
|
||||
"sessions.sidebar.session.export.failedRevealPath": "Не вдалося відкрити шлях",
|
||||
"sessions.sidebar.session.export.untitledSubagent": "Під-агент без назви",
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate": "Ця дія назавжди видаляє сесії {count} з {dateLabel}.",
|
||||
"sessions.sidebar.dialogs.sessionList.more": "+{count} більше",
|
||||
"sessions.sidebar.session.share.successTitle": "Сесією поділилися",
|
||||
"sessions.sidebar.session.share.successDescription": "Ви можете скопіювати посилання з меню.",
|
||||
"sessions.sidebar.session.share.successDescription": "Посилання для поширення скопійовано в буфер обміну.",
|
||||
"sessions.sidebar.session.share.error": "Не вдалося поділитися сесією",
|
||||
"sessions.sidebar.session.share.copyUrlError": "Не вдалося скопіювати URL",
|
||||
"sessions.sidebar.session.unshare.success": "Спільний доступ до сесій закрито",
|
||||
|
||||
@@ -476,6 +476,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.project.actions.edit': '编辑',
|
||||
'sessions.sidebar.project.actions.newDraftSession': '新建草稿会话',
|
||||
'sessions.sidebar.session.menu.rename': '重命名',
|
||||
'sessions.sidebar.session.menu.copyId': '复制会话 ID',
|
||||
'sessions.sidebar.session.copyId.success': '会话 ID 已复制',
|
||||
'sessions.sidebar.session.copyId.error': '无法复制会话 ID',
|
||||
'header.sessionActions.openAria': '打开会话操作',
|
||||
'sessions.sidebar.session.rename.save': '保存会话名称',
|
||||
'sessions.sidebar.session.rename.cancel': '取消重命名会话',
|
||||
'sessions.sidebar.session.menu.unpin': '取消置顶会话',
|
||||
@@ -498,6 +502,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.label': '会话菜单',
|
||||
'sessions.sidebar.session.untitled': '未命名会话',
|
||||
'sessions.sidebar.session.export.nothingToExport': '没有可导出的内容',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': '无法加载完整的会话历史记录',
|
||||
'sessions.sidebar.session.export.success': '会话已导出',
|
||||
'sessions.sidebar.session.export.failedRevealPath': '显示路径失败',
|
||||
'sessions.sidebar.session.export.untitledSubagent': '未命名子智能体',
|
||||
@@ -553,7 +558,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': '此操作将永久删除来自 {dateLabel} 的 {count} 个会话。',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count} 更多',
|
||||
'sessions.sidebar.session.share.successTitle': '会话已分享',
|
||||
'sessions.sidebar.session.share.successDescription': '你可以在菜单中复制链接。',
|
||||
'sessions.sidebar.session.share.successDescription': '分享链接已复制到剪贴板。',
|
||||
'sessions.sidebar.session.share.error': '无法分享会话',
|
||||
'sessions.sidebar.session.share.copyUrlError': '复制 URL 失败',
|
||||
'sessions.sidebar.session.unshare.success': '会话已取消分享',
|
||||
|
||||
@@ -489,6 +489,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.project.actions.edit': '編輯',
|
||||
'sessions.sidebar.project.actions.newDraftSession': '新增草稿會話',
|
||||
'sessions.sidebar.session.menu.rename': '重新命名',
|
||||
'sessions.sidebar.session.menu.copyId': '複製工作階段 ID',
|
||||
'sessions.sidebar.session.copyId.success': '已複製工作階段 ID',
|
||||
'sessions.sidebar.session.copyId.error': '無法複製工作階段 ID',
|
||||
'header.sessionActions.openAria': '開啟工作階段操作',
|
||||
'sessions.sidebar.session.rename.save': '儲存會話名稱',
|
||||
'sessions.sidebar.session.rename.cancel': '取消重新命名會話',
|
||||
'sessions.sidebar.session.menu.unpin': '取消釘選會話',
|
||||
@@ -511,6 +515,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.label': '會話選單',
|
||||
'sessions.sidebar.session.untitled': '未命名會話',
|
||||
'sessions.sidebar.session.export.nothingToExport': '沒有可匯出的內容',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': '無法載入完整的工作階段記錄',
|
||||
'sessions.sidebar.session.export.success': '會話已匯出',
|
||||
'sessions.sidebar.session.export.failedRevealPath': '顯示路徑失敗',
|
||||
'sessions.sidebar.session.export.untitledSubagent': '未命名子 Agent',
|
||||
@@ -566,7 +571,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': '此操作將永久刪除來自 {dateLabel} 的 {count} 個會話。',
|
||||
'sessions.sidebar.dialogs.sessionList.more': '+{count} 更多',
|
||||
'sessions.sidebar.session.share.successTitle': '會話已分享',
|
||||
'sessions.sidebar.session.share.successDescription': '你可以在選單中複製連結。',
|
||||
'sessions.sidebar.session.share.successDescription': '分享連結已複製到剪貼簿。',
|
||||
'sessions.sidebar.session.share.error': '無法分享會話',
|
||||
'sessions.sidebar.session.share.copyUrlError': '複製 URL 失敗',
|
||||
'sessions.sidebar.session.unshare.success': '會話已取消分享',
|
||||
|
||||
@@ -39,6 +39,21 @@ describe('useGlobalSessionsStore', () => {
|
||||
expect(useGlobalSessionsStore.getState().activeSessions[0]?.share?.url).toBe('https://share.example/b');
|
||||
});
|
||||
|
||||
test('publishes an updated session when sharing is removed', () => {
|
||||
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a'));
|
||||
const sharedSessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
|
||||
useGlobalSessionsStore.getState().upsertSession({
|
||||
...buildSession('https://share.example/a'),
|
||||
share: undefined,
|
||||
time: { created: 1, updated: 3 },
|
||||
});
|
||||
|
||||
const unsharedSessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
expect(unsharedSessions).not.toBe(sharedSessions);
|
||||
expect(unsharedSessions[0]?.share).toBe(undefined);
|
||||
});
|
||||
|
||||
test('preserves directory metadata when a live update omits it', () => {
|
||||
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a', { directory: '/repo/app' }));
|
||||
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/b', {
|
||||
|
||||
@@ -467,11 +467,28 @@ describe("shareSession live state", () => {
|
||||
|
||||
const result = await unshareSession("session-a")
|
||||
|
||||
expect(result).toBe(unsharedSession)
|
||||
expect(result).toEqual({ ...unsharedSession, share: undefined })
|
||||
expect(replyCalls.find((call) => call.method === "session.unshare")?.params.directory).toBe("/test/project")
|
||||
expect(sessionStore.getState().session[0].share).toBe(undefined)
|
||||
expect(otherStore.getState().session[0].id).toBe("other")
|
||||
expect(globalUpsertedSessions).toEqual([unsharedSession])
|
||||
expect(globalUpsertedSessions).toEqual([{ ...unsharedSession, share: undefined }])
|
||||
})
|
||||
|
||||
test("clears a stale share URL echoed by a successful unshare response", async () => {
|
||||
const sharedSession = { id: "session-a", time: { created: 1 }, share: { url: "https://share.example/a" } } as Session
|
||||
const staleResponse = { id: "session-a", time: { created: 1, updated: 2 }, share: { url: "https://share.example/a" } } as Session
|
||||
const sessionStore = createStore({}, { session: [sharedSession] })
|
||||
const childStores = createChildStores([["/test/project", sessionStore]])
|
||||
sessionShareResult = { data: staleResponse }
|
||||
|
||||
const { setActionRefs, unshareSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
||||
|
||||
const result = await unshareSession("session-a")
|
||||
|
||||
expect(result?.share).toBe(undefined)
|
||||
expect(sessionStore.getState().session[0].share).toBe(undefined)
|
||||
expect((globalUpsertedSessions[0] as Session).share).toBe(undefined)
|
||||
})
|
||||
|
||||
test("updates the directory live store after sharing", async () => {
|
||||
@@ -492,7 +509,7 @@ describe("shareSession live state", () => {
|
||||
expect(globalUpsertedSessions).toEqual([sharedSession])
|
||||
})
|
||||
|
||||
test("preserves live directory metadata while clearing share from null response", async () => {
|
||||
test("preserves live directory metadata while normalizing a null share response", async () => {
|
||||
const sharedSession = {
|
||||
id: "session-a",
|
||||
time: { created: 1 },
|
||||
@@ -514,8 +531,8 @@ describe("shareSession live state", () => {
|
||||
|
||||
await unshareSession("session-a")
|
||||
|
||||
const liveSession = sessionStore.getState().session[0] as SessionWithDirectory & { share?: null }
|
||||
expect(liveSession.share).toBe(null)
|
||||
const liveSession = sessionStore.getState().session[0] as SessionWithDirectory
|
||||
expect(liveSession.share).toBe(undefined)
|
||||
expect(liveSession.directory).toBe("/test/project")
|
||||
expect(liveSession.project?.worktree).toBe("/test/project")
|
||||
})
|
||||
|
||||
@@ -843,7 +843,13 @@ export async function shareSession(sessionId: string): Promise<Session | null> {
|
||||
export async function unshareSession(sessionId: string): Promise<Session | null> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory })
|
||||
const session = stripSessionDiffSnapshots(assertSdkData(result, "session.unshare"))
|
||||
// A successful unshare is authoritative even when the upstream response
|
||||
// echoes the pre-mutation session with its old share URL. Normalize that
|
||||
// stale field at the action boundary before publishing to either store.
|
||||
const session = {
|
||||
...stripSessionDiffSnapshots(assertSdkData(result, "session.unshare")),
|
||||
share: undefined,
|
||||
}
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
updateLiveSession(session, sessionDirectory)
|
||||
return session
|
||||
|
||||
@@ -88,6 +88,91 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("loads every history page for an explicit complete-history request", async () => {
|
||||
const calls: Array<{ before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||
calls.push({ before })
|
||||
if (!before) return response([createRecord(sessionID, "msg_latest")], "cursor-2")
|
||||
if (before === "cursor-2") return response([createRecord(sessionID, "msg_middle")], "cursor-1")
|
||||
return response([createRecord(sessionID, "msg_oldest")])
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await loader.loadComplete(target)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ before: undefined },
|
||||
{ before: "cursor-2" },
|
||||
{ before: "cursor-1" },
|
||||
])
|
||||
expect(loader.getSnapshot(target).complete).toBe(true)
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toHaveLength(3)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects a complete-history request when its initial load fails", async () => {
|
||||
const { childStores, loader } = createLoader(async () => ({
|
||||
error: { message: "rejected" },
|
||||
response: { status: 400 },
|
||||
}))
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("session.messages failed (400): rejected")
|
||||
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects a complete-history request when an older page fails", async () => {
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => before
|
||||
? { error: { message: "older rejected" }, response: { status: 400 } }
|
||||
: response([createRecord(sessionID)], "older-cursor"))
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("session.messages failed (400): older rejected")
|
||||
|
||||
expect(loader.getSnapshot(target).cursor).toBe("older-cursor")
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("fetches authoritative coverage when renderable messages have no loader metadata", async () => {
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async ({ sessionID }) => {
|
||||
calls += 1
|
||||
return response([createRecord(sessionID)])
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
childStores.ensureChild(target.directory, { bootstrap: false }).setState({
|
||||
message: { [target.sessionID]: [createRecord(target.sessionID, "cached").info] },
|
||||
})
|
||||
|
||||
await loader.loadComplete(target)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(loader.getSnapshot(target).complete).toBe(true)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects repeated pagination cursors instead of looping forever", async () => {
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||
calls += 1
|
||||
if (!before) return response([createRecord(sessionID, "latest")], "cursor-a")
|
||||
if (before === "cursor-a") return response([createRecord(sessionID, "middle")], "cursor-b")
|
||||
return response([createRecord(sessionID, "older")], "cursor-a")
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("Session history pagination made no progress")
|
||||
|
||||
expect(calls).toBe(3)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("runs a requested tail refresh after an older in-flight load", async () => {
|
||||
const initial = deferred<ReturnType<typeof response>>()
|
||||
const refresh = deferred<ReturnType<typeof response>>()
|
||||
|
||||
@@ -242,6 +242,27 @@ export class SessionMessageLoader {
|
||||
})
|
||||
}
|
||||
|
||||
async loadComplete(target: SessionMessageTarget): Promise<void> {
|
||||
const normalized = this.normalizeTarget(target)
|
||||
if (!normalized || this.disposed) throw new Error("Session message loader is unavailable")
|
||||
const initial = this.getSnapshot(normalized)
|
||||
await this.ensure(normalized, { force: !initial.resolved })
|
||||
|
||||
const visitedCursors = new Set<string>()
|
||||
while (true) {
|
||||
const snapshot = this.getSnapshot(normalized)
|
||||
if (snapshot.status === "error") throw snapshot.error ?? new Error("Session history could not be loaded")
|
||||
if (snapshot.complete) return
|
||||
if (!snapshot.cursor) throw new Error("Session history coverage is unresolved")
|
||||
if (visitedCursors.has(snapshot.cursor)) {
|
||||
throw new Error("Session history pagination made no progress")
|
||||
}
|
||||
visitedCursors.add(snapshot.cursor)
|
||||
|
||||
await this.loadOlder(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
refreshTail(target: SessionMessageTarget, limit: number): Promise<void> {
|
||||
const normalized = this.normalizeTarget(target)
|
||||
if (!normalized || this.disposed) return Promise.resolve()
|
||||
|
||||
@@ -320,6 +320,14 @@ export function useSync() {
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const loadCompleteHistory = useCallback(
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
touch(sessionID, targetDirectory)
|
||||
await messageLoader.loadComplete({ directory: targetDirectory, sessionID })
|
||||
},
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const prefetchSession = useCallback(
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return
|
||||
@@ -396,6 +404,7 @@ export function useSync() {
|
||||
syncSession,
|
||||
prefetchSession,
|
||||
loadMore,
|
||||
loadCompleteHistory,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isComplete,
|
||||
@@ -405,6 +414,6 @@ export function useSync() {
|
||||
confirm: optimisticConfirm,
|
||||
},
|
||||
}),
|
||||
[syncSession, prefetchSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user