diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4a788066..2a8433ce 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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 = ({ @@ -748,6 +755,7 @@ export const Header: React.FC = ({ 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 = ({ 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(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 = ({ 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. */}
{activeSurfaceHeader ? (
@@ -2083,37 +2231,123 @@ export const Header: React.FC = ({ ) : null}
) : ( - - + ) : null} - - +
+ {isRenamingHeaderSession ? ( +
{ + event.preventDefault(); + void saveHeaderSessionRename(); + }} + > + 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" + /> + + +
+ ) : ( + + {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} + + )} + {(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? ( + + {activeProjectLabel ? {activeProjectLabel} : null} + {currentBranchLabel ? ( + + + {currentBranchLabel} + + ) : null} + {!isNewSessionDraftOpen && worktreeBadgeKind ? ( + + + {worktreeBadge} + + ) : null} + + ) : null} +
+
+ {currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( + { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} + > + + + + + { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} + {t('sessions.sidebar.session.menu.copyId')} + + {currentSession?.shareUrl ? ( + <> + {t('sessions.sidebar.session.menu.copyLink')} + void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + + ) : ( + void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + )} + void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} + + setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} + setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + + + ) : null} +
+
)} {tabs.length > 0 && ( @@ -2595,6 +2829,28 @@ export const Header: React.FC = ({ > {isMobile ? renderMobile() : renderDesktop()} + { if (!open) setPendingHeaderRetentionAction(null); }}> + + + {pendingHeaderRetentionAction === 'delete' + ? t('sessions.sidebar.dialogs.deleteSession.title') + : t('sessions.sidebar.dialogs.archiveSession.title')} + {pendingHeaderRetentionAction === 'delete' + ? t('sessions.sidebar.dialogs.deleteSession.single', { sessionTitle: currentSessionTitle }) + : t('sessions.sidebar.dialogs.archiveSession.single', { sessionTitle: currentSessionTitle })} + + + + + + + = ({ handleCancelEdit, handleShareSession, handleCopyShareUrl, + handleCopySessionId, handleUnshareSession, handleDeleteSession, confirmDeleteSession, @@ -907,6 +908,7 @@ const SessionSidebarComponent: React.FC = ({ 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 = ({ handleShareSession={stableHandleShareSession} copiedSessionId={copiedSessionId} handleCopyShareUrl={stableHandleCopyShareUrl} + handleCopySessionId={stableHandleCopySessionId} handleUnshareSession={stableHandleUnshareSession} openSidebarMenuKey={openSidebarMenuKey} setOpenSidebarMenuKey={setOpenSidebarMenuKey} diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 53081f25..904feb29 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -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 { {t('sessions.sidebar.session.menu.rename')} + handleCopySessionId(session.id)} className="[&>svg]:mr-1"> + + {t('sessions.sidebar.session.menu.copyId')} + sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1"> {isPinnedSession ? : } {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 diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index bdc91a5e..23a139a4 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -117,36 +117,52 @@ export const useSessionActions = (args: Args) => { args.setEditTitle(''); }, [args]); + const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise => { + 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, diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index b201235b..8816fc73 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index f5144b9d..c5230cd6 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -476,6 +476,10 @@ export const dict: Record = { "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 = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 159be205..9c65fdc5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d31555e2..27f9ce5f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -476,6 +476,10 @@ export const dict: Record = { '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 = { '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 = { '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': 'セッションの共有を解除しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index a06fbcda..90349033 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -476,6 +476,10 @@ export const dict: Record = { '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 = { '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 = { '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': '세션 공유 해제됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ac1f8e11..72606065 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -279,6 +279,10 @@ export const dict: Record = { '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 = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a7812c58..e3d9157e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -476,6 +476,10 @@ export const dict: Record = { "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 = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a0095e81..d19ad98b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -476,6 +476,10 @@ export const dict: Record = { "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 = { "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 = { "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": "Спільний доступ до сесій закрито", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index cdc65f88..9481db7c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -476,6 +476,10 @@ export const dict: Record = { '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 = { '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 = { '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': '会话已取消分享', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ba25880a..8bacf569 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -489,6 +489,10 @@ export const dict: Record = { '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 = { '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 = { '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': '會話已取消分享', diff --git a/packages/ui/src/stores/useGlobalSessionsStore.test.ts b/packages/ui/src/stores/useGlobalSessionsStore.test.ts index e068fbf2..9cd4d520 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.test.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.test.ts @@ -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', { diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index e0dbf858..cf60e16a 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -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") }) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 8ea4c39d..d9aff83c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -843,7 +843,13 @@ export async function shareSession(sessionId: string): Promise { export async function unshareSession(sessionId: string): Promise { 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 diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index 091a16a0..68508158 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -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>() const refresh = deferred>() diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index cc891833..ef29e1a9 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -242,6 +242,27 @@ export class SessionMessageLoader { }) } + async loadComplete(target: SessionMessageTarget): Promise { + 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() + 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 { const normalized = this.normalizeTarget(target) if (!normalized || this.disposed) return Promise.resolve() diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 5e43751c..83e179e2 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -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], ) }