Merge pull request #2616 from makeittech/feat/restore-archived-sessions-2346
feat: add restore/unarchive for archived sessions
This commit is contained in:
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
|
||||
const {
|
||||
copiedSessionId,
|
||||
@@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
mobileVariant,
|
||||
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
unarchiveSession,
|
||||
childrenMap,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
|
||||
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
|
||||
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
|
||||
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
|
||||
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
|
||||
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
@@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
openContextPanelTab={openContextPanelTab}
|
||||
handleDeleteSession={stableHandleDeleteSession}
|
||||
handleRestoreSession={stableHandleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
renderSessionNode={renderSessionNode}
|
||||
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
} = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulkCanRemoveFromFolder}
|
||||
onRestore={handleBulkRestore}
|
||||
onDelete={handleBulkDelete}
|
||||
onDone={handleExitSelectionMode}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
onRemoveFromFolder: () => void;
|
||||
canRemoveFromFolder: boolean;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
onCreateFolderAndMove,
|
||||
onRemoveFromFolder,
|
||||
canRemoveFromFolder,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onDone,
|
||||
}) => {
|
||||
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{archivedBucket ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.restore')}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
|
||||
@@ -92,6 +92,7 @@ type Props = {
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (
|
||||
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{t('sessions.sidebar.bulkActions.archive')}
|
||||
</Item>
|
||||
) : null}
|
||||
{archivedBucket ? (
|
||||
<Item className="[&>svg]:mr-1" onClick={() => handleRestoreSession(session)}>
|
||||
<Icon name="inbox-unarchive" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.restore')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket, hardDelete: true })}>
|
||||
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.delete')}
|
||||
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type Args = {
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSession: (id: string) => Promise<boolean>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
|
||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||
}, [args, executeDeleteSession]);
|
||||
|
||||
const handleRestoreSession = React.useCallback(
|
||||
async (session: Session) => {
|
||||
const success = await args.unarchiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
},
|
||||
[args, t],
|
||||
);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
handleSessionSelect,
|
||||
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type Args = {
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
|
||||
sessionCount: number;
|
||||
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
} = args;
|
||||
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
|
||||
|
||||
const handleBulkRestore = React.useCallback(async () => {
|
||||
if (!hasSelection || !bulkScopeIsArchived) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const { restoredIds, failedIds } = await unarchiveSessions(ids);
|
||||
if (restoredIds.length > 0) {
|
||||
toast.success(restoredIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
|
||||
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
});
|
||||
}, [t, unarchiveSession]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderDirectoryItem = (
|
||||
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-14 focus-within:pr-14"
|
||||
onClick={() => openSession(session)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
|
||||
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
|
||||
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
restoreSession(session);
|
||||
}}
|
||||
className="absolute right-7 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-foreground group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.restoreSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -426,6 +426,11 @@ export const dict = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen',
|
||||
'sessions.sidebar.bulkActions.restore': 'Wiederherstellen',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': '{count} Sitzung wiederhergestellt',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': '{count} Sitzungen wiederhergestellt',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Fehler beim Wiederherstellen von {count} Sitzung',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': 'Fehler beim Wiederherstellen von {count} Sitzungen',
|
||||
'sessions.sidebar.folders.none': 'Noch keine Ordner',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...',
|
||||
'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen',
|
||||
@@ -517,6 +522,8 @@ export const dict = {
|
||||
'sessions.sidebar.session.delete.error': 'Fehler beim Löschen der Sitzung',
|
||||
'sessions.sidebar.session.archive.success': 'Sitzung archiviert',
|
||||
'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung',
|
||||
'sessions.sidebar.session.restore.success': 'Sitzung wiederhergestellt',
|
||||
'sessions.sidebar.session.restore.error': 'Fehler beim Wiederherstellen der Sitzung',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} Checks bestanden',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend',
|
||||
@@ -2787,6 +2794,7 @@ export const dict = {
|
||||
'sessions.archivePage.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen',
|
||||
'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen',
|
||||
'sessions.archivePage.deleteSessionAria': '{title} löschen',
|
||||
'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen',
|
||||
'header.sessionActions.openAria': 'Sitzungsaktionen öffnen',
|
||||
'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren',
|
||||
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
|
||||
|
||||
@@ -449,6 +449,7 @@ export const dict = {
|
||||
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
|
||||
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
|
||||
'sessions.archivePage.deleteSessionAria': 'Delete {title}',
|
||||
'sessions.archivePage.restoreSessionAria': 'Restore {title}',
|
||||
'sessions.switcher.openAria': 'Open session switcher',
|
||||
'sessions.switcher.empty': 'No recent sessions',
|
||||
'sessions.switcher.draftTitle': 'New session',
|
||||
@@ -470,6 +471,11 @@ export const dict = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': 'Failed to archive {count} sessions',
|
||||
'sessions.sidebar.bulkActions.restore': 'Restore',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': 'Restored {count} session',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': 'Restored {count} sessions',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Failed to restore {count} session',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': 'Failed to restore {count} sessions',
|
||||
'sessions.sidebar.folders.none': 'No folders yet',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': 'New folder...',
|
||||
'sessions.sidebar.folders.removeFromFolder': 'Remove from folder',
|
||||
@@ -573,6 +579,8 @@ export const dict = {
|
||||
'sessions.sidebar.session.delete.error': 'Failed to delete session',
|
||||
'sessions.sidebar.session.archive.success': 'Session archived',
|
||||
'sessions.sidebar.session.archive.error': 'Failed to archive session',
|
||||
'sessions.sidebar.session.restore.success': 'Session restored',
|
||||
'sessions.sidebar.session.restore.error': 'Failed to restore session',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} checks passed',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count} failing',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} pending',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
|
||||
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
|
||||
"sessions.archivePage.deleteSessionAria": "Eliminar {title}",
|
||||
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
|
||||
"sessions.switcher.openAria": "Abrir selector de sesiones",
|
||||
"sessions.switcher.empty": "No hay sesiones recientes",
|
||||
"sessions.switcher.draftTitle": "Nueva sesión",
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.bulkActions.archivedPlural": "Se archivaron {count} sesiones",
|
||||
"sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión",
|
||||
"sessions.sidebar.bulkActions.failedArchivePlural": "No se pudo archivar {count} sesiones",
|
||||
"sessions.sidebar.bulkActions.restore": "Restaurar",
|
||||
"sessions.sidebar.bulkActions.restoredSingle": "Se restauró {count} sesión",
|
||||
"sessions.sidebar.bulkActions.restoredPlural": "Se restauraron {count} sesiones",
|
||||
"sessions.sidebar.bulkActions.failedRestoreSingle": "No se pudo restaurar {count} sesión",
|
||||
"sessions.sidebar.bulkActions.failedRestorePlural": "No se pudo restaurar {count} sesiones",
|
||||
"sessions.sidebar.folders.none": "No hay carpetas aún",
|
||||
"sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...",
|
||||
"sessions.sidebar.folders.removeFromFolder": "Quitar de carpeta",
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.delete.error": "No se pudo eliminar la sesión",
|
||||
"sessions.sidebar.session.archive.success": "Sesión archivada",
|
||||
"sessions.sidebar.session.archive.error": "No se pudo archivar la sesión",
|
||||
"sessions.sidebar.session.restore.success": "Sesión restaurada",
|
||||
"sessions.sidebar.session.restore.error": "No se pudo restaurar la sesión",
|
||||
"sessions.sidebar.group.pr.checksPassed": "{success}/{total} comprobaciones aprobadas",
|
||||
"sessions.sidebar.group.pr.failingCount": "{count} con fallos",
|
||||
"sessions.sidebar.group.pr.pendingCount": "{count} pendientes",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict = {
|
||||
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet',
|
||||
'sessions.archivePage.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
|
||||
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
|
||||
'sessions.archivePage.restoreSessionAria': 'Restaurer {title}',
|
||||
'sessions.switcher.openAria': 'Sélecteur de session ouvert',
|
||||
'sessions.switcher.empty': 'Aucune session récente',
|
||||
'sessions.switcher.draftTitle': 'Nouvelle session',
|
||||
@@ -306,6 +307,11 @@ export const dict = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Échec de l\'archivage de la session {count}',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': 'Échec de l\'archivage des sessions {count}',
|
||||
'sessions.sidebar.bulkActions.restore': 'Restaurer',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': 'Session {count} restaurée',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': 'Sessions {count} restaurées',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Échec de la restauration de la session {count}',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': 'Échec de la restauration des sessions {count}',
|
||||
'sessions.sidebar.folders.none': 'Aucun dossier pour l\'instant',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...',
|
||||
'sessions.sidebar.folders.removeFromFolder': 'Supprimer du dossier',
|
||||
@@ -409,6 +415,8 @@ export const dict = {
|
||||
'sessions.sidebar.session.delete.error': 'Échec de la suppression de la session',
|
||||
'sessions.sidebar.session.archive.success': 'Session archivée',
|
||||
'sessions.sidebar.session.archive.error': 'Échec de l\'archivage de la session',
|
||||
'sessions.sidebar.session.restore.success': 'Session restaurée',
|
||||
'sessions.sidebar.session.restore.error': 'Échec de la restauration de la session',
|
||||
'sessions.sidebar.group.pr.checksPassed': 'Contrôles {success}/{total} réussis',
|
||||
'sessions.sidebar.group.pr.failingCount': 'Échec de {count}',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} en attente',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
|
||||
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
|
||||
'sessions.archivePage.deleteSessionAria': '{title} を削除',
|
||||
'sessions.archivePage.restoreSessionAria': '{title} を復元',
|
||||
'sessions.switcher.openAria': 'セッションスイッチャーを開く',
|
||||
'sessions.switcher.empty': '最近のセッションはありません',
|
||||
'sessions.switcher.draftTitle': '新しいセッション',
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': '{count}セッションのアーカイブに失敗しました',
|
||||
'sessions.sidebar.bulkActions.restore': '復元',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': '{count}セッションを復元しました',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': '{count}セッションを復元しました',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': '{count}セッションの復元に失敗しました',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': '{count}セッションの復元に失敗しました',
|
||||
'sessions.sidebar.folders.none': 'まだフォルダがありません',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...',
|
||||
'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除',
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました',
|
||||
'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました',
|
||||
'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました',
|
||||
'sessions.sidebar.session.restore.success': 'セッションを復元しました',
|
||||
'sessions.sidebar.session.restore.error': 'セッションの復元に失敗しました',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total}のチェックに合格',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count}件失敗',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count}件保留中',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
|
||||
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
|
||||
'sessions.archivePage.deleteSessionAria': '{title} 삭제',
|
||||
'sessions.archivePage.restoreSessionAria': '{title} 복원',
|
||||
'sessions.switcher.openAria': '세션 전환기 열기',
|
||||
'sessions.switcher.empty': '최근 세션 없음',
|
||||
'sessions.switcher.draftTitle': '새 세션',
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': '세션 {count}개 보관 실패',
|
||||
'sessions.sidebar.bulkActions.restore': '복원',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': '세션 {count}개 복원됨',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': '세션 {count}개 복원됨',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': '세션 {count}개 복원 실패',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': '세션 {count}개 복원 실패',
|
||||
'sessions.sidebar.folders.none': '아직 폴더 없음',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…',
|
||||
'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거',
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.delete.error': '세션 삭제 실패',
|
||||
'sessions.sidebar.session.archive.success': '세션 보관됨',
|
||||
'sessions.sidebar.session.archive.error': '세션 보관 실패',
|
||||
'sessions.sidebar.session.restore.success': '세션 복원됨',
|
||||
'sessions.sidebar.session.restore.error': '세션 복원 실패',
|
||||
'sessions.sidebar.group.pr.checksPassed': '검사 통과: {success}/{total}',
|
||||
'sessions.sidebar.group.pr.failingCount': '실패 {count}개',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중',
|
||||
|
||||
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
|
||||
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
|
||||
'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
|
||||
'sessions.archivePage.restoreSessionAria': 'Przywróć {title}',
|
||||
'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
|
||||
'sessions.switcher.empty': 'Brak ostatnich sesji',
|
||||
'sessions.switcher.draftTitle': 'Nowa sesja',
|
||||
@@ -333,6 +334,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': 'Zarchiwizowano {count} sesji',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Nie udało się zarchiwizować {count} sesji',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': 'Nie udało się zarchiwizować {count} sesji',
|
||||
'sessions.sidebar.bulkActions.restore': 'Przywróć',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': 'Przywrócono {count} sesję',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': 'Przywrócono {count} sesji',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': 'Nie udało się przywrócić {count} sesji',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': 'Nie udało się przywrócić {count} sesji',
|
||||
'sessions.scheduledTasks.dialog.title': 'Zaplanowane zadania',
|
||||
'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.',
|
||||
'sessions.scheduledTasks.dialog.project.label': 'Projekt',
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.delete.error': 'Nie udało się usunąć sesji',
|
||||
'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana',
|
||||
'sessions.sidebar.session.archive.error': 'Nie udało się zarchiwizować sesji',
|
||||
'sessions.sidebar.session.restore.success': 'Sesja przywrócona',
|
||||
'sessions.sidebar.session.restore.error': 'Nie udało się przywrócić sesji',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} testów przeszło',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} oczekujących',
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.archivePage.deleteProject": "Excluir todas as sessões arquivadas deste projeto",
|
||||
"sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
|
||||
"sessions.archivePage.deleteSessionAria": "Excluir {title}",
|
||||
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
|
||||
"sessions.switcher.openAria": "Abrir seletor de sessões",
|
||||
"sessions.switcher.empty": "Nenhuma sessão recente",
|
||||
"sessions.switcher.draftTitle": "Nova sessão",
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.bulkActions.archivedPlural": "{count} sessões arquivadas",
|
||||
"sessions.sidebar.bulkActions.failedArchiveSingle": "Não foi possível arquivar {count} sessão",
|
||||
"sessions.sidebar.bulkActions.failedArchivePlural": "Não foi possível arquivar {count} sessões",
|
||||
"sessions.sidebar.bulkActions.restore": "Restaurar",
|
||||
"sessions.sidebar.bulkActions.restoredSingle": "{count} sessão restaurada",
|
||||
"sessions.sidebar.bulkActions.restoredPlural": "{count} sessões restauradas",
|
||||
"sessions.sidebar.bulkActions.failedRestoreSingle": "Não foi possível restaurar {count} sessão",
|
||||
"sessions.sidebar.bulkActions.failedRestorePlural": "Não foi possível restaurar {count} sessões",
|
||||
"sessions.sidebar.folders.none": "Não há pastas ainda",
|
||||
"sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...",
|
||||
"sessions.sidebar.folders.removeFromFolder": "Remover da pasta",
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.delete.error": "Não foi possível excluir a sessão",
|
||||
"sessions.sidebar.session.archive.success": "Sessão archivada",
|
||||
"sessions.sidebar.session.archive.error": "Não foi possível arquivar a sessão",
|
||||
"sessions.sidebar.session.restore.success": "Sessão restaurada",
|
||||
"sessions.sidebar.session.restore.error": "Não foi possível restaurar a sessão",
|
||||
"sessions.sidebar.group.pr.checksPassed": "{success}/{total} checks pasadas",
|
||||
"sessions.sidebar.group.pr.failingCount": "{count} com fallos",
|
||||
"sessions.sidebar.group.pr.pendingCount": "{count} pendentes",
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
|
||||
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
|
||||
"sessions.archivePage.deleteSessionAria": "Видалити {title}",
|
||||
"sessions.archivePage.restoreSessionAria": "Відновити {title}",
|
||||
"sessions.switcher.openAria": "Відкрити перемикач сесій",
|
||||
"sessions.switcher.empty": "Немає недавніх сесій",
|
||||
"sessions.switcher.draftTitle": "Нова сесія",
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
|
||||
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
|
||||
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}",
|
||||
"sessions.sidebar.bulkActions.restore": "Відновити",
|
||||
"sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}",
|
||||
"sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}",
|
||||
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}",
|
||||
"sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}",
|
||||
"sessions.sidebar.folders.none": "Папок ще немає",
|
||||
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
|
||||
"sessions.sidebar.folders.removeFromFolder": "Видалити з папки",
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.delete.error": "Не вдалося видалити сесію",
|
||||
"sessions.sidebar.session.archive.success": "Сесію заархівовано",
|
||||
"sessions.sidebar.session.archive.error": "Не вдалося заархівувати сесію",
|
||||
"sessions.sidebar.session.restore.success": "Сесію відновлено",
|
||||
"sessions.sidebar.session.restore.error": "Не вдалося відновити сесію",
|
||||
"sessions.sidebar.group.pr.checksPassed": "Перевірки {success}/{total} пройдено",
|
||||
"sessions.sidebar.group.pr.failingCount": "{count} з помилкою",
|
||||
"sessions.sidebar.group.pr.pendingCount": "{count} очікує",
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话',
|
||||
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
|
||||
'sessions.archivePage.deleteSessionAria': '删除 {title}',
|
||||
'sessions.archivePage.restoreSessionAria': '还原 {title}',
|
||||
'sessions.switcher.openAria': '打开会话切换器',
|
||||
'sessions.switcher.empty': '没有最近会话',
|
||||
'sessions.switcher.draftTitle': '新会话',
|
||||
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': '归档 {count} 个会话失败',
|
||||
'sessions.sidebar.bulkActions.restore': '还原',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': '已还原 {count} 个会话',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': '已还原 {count} 个会话',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': '还原 {count} 个会话失败',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': '还原 {count} 个会话失败',
|
||||
'sessions.sidebar.folders.none': '暂无文件夹',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...',
|
||||
'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除',
|
||||
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.delete.error': '删除会话失败',
|
||||
'sessions.sidebar.session.archive.success': '会话已归档',
|
||||
'sessions.sidebar.session.archive.error': '归档会话失败',
|
||||
'sessions.sidebar.session.restore.success': '会话已还原',
|
||||
'sessions.sidebar.session.restore.error': '还原会话失败',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 项检查已通过',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count} 项失败',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中',
|
||||
|
||||
@@ -463,6 +463,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段',
|
||||
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
|
||||
'sessions.archivePage.deleteSessionAria': '刪除 {title}',
|
||||
'sessions.archivePage.restoreSessionAria': '還原 {title}',
|
||||
'sessions.switcher.openAria': '開啟會話切換器',
|
||||
'sessions.switcher.empty': '沒有最近會話',
|
||||
'sessions.switcher.draftTitle': '新會話',
|
||||
@@ -484,6 +485,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話',
|
||||
'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗',
|
||||
'sessions.sidebar.bulkActions.failedArchivePlural': '封存 {count} 個會話失敗',
|
||||
'sessions.sidebar.bulkActions.restore': '還原',
|
||||
'sessions.sidebar.bulkActions.restoredSingle': '已還原 {count} 個會話',
|
||||
'sessions.sidebar.bulkActions.restoredPlural': '已還原 {count} 個會話',
|
||||
'sessions.sidebar.bulkActions.failedRestoreSingle': '還原 {count} 個會話失敗',
|
||||
'sessions.sidebar.bulkActions.failedRestorePlural': '還原 {count} 個會話失敗',
|
||||
'sessions.sidebar.folders.none': '暫無資料夾',
|
||||
'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...',
|
||||
'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除',
|
||||
@@ -587,6 +593,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.delete.error': '刪除會話失敗',
|
||||
'sessions.sidebar.session.archive.success': '會話已封存',
|
||||
'sessions.sidebar.session.archive.error': '封存會話失敗',
|
||||
'sessions.sidebar.session.restore.success': '會話已還原',
|
||||
'sessions.sidebar.session.restore.error': '還原會話失敗',
|
||||
'sessions.sidebar.group.pr.checksPassed': '{success}/{total} 項檢查已通過',
|
||||
'sessions.sidebar.group.pr.failingCount': '{count} 項失敗',
|
||||
'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中',
|
||||
|
||||
@@ -59,8 +59,8 @@ User-visible session ordering is also not owned by the global cache array order.
|
||||
|
||||
Global refresh rules:
|
||||
|
||||
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
|
||||
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
|
||||
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. The global cache therefore loads with one inclusive request (`archived: true`) and splits active/archived client-side via `splitGlobalSessionsByArchived` — an `archived: false` request cannot be truthful because the server filter excludes restored sessions (`time.archived` falsy-but-present, see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`). For callers that still want only archived records, `listGlobalSessionPages` narrows inclusive responses at the data boundary (default `narrowToArchived`), so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
|
||||
- Per-directory refresh issues one inclusive request per directory (previously two), bounded to two requests across callers and prioritizing the current directory.
|
||||
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
|
||||
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
|
||||
- Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
|
||||
import { listGlobalSessionPages } from './globalSessions'
|
||||
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
|
||||
|
||||
describe('listGlobalSessionPages', () => {
|
||||
test('sanitizes session list records before returning them', async () => {
|
||||
@@ -138,6 +138,27 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2'])
|
||||
})
|
||||
|
||||
test('returns the inclusive response unfiltered when narrowing is disabled', async () => {
|
||||
const apiClient = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: 'ses_active', time: { created: 1, updated: 20 } },
|
||||
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
|
||||
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
|
||||
],
|
||||
response: { headers: new Headers() },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const sessions = await listGlobalSessionPages(apiClient, { archived: true, narrowToArchived: false, pageSize: 500 })
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_active', 'ses_archived', 'ses_restored'])
|
||||
})
|
||||
|
||||
test('keeps paginating archived pages that are full of non-archived records', async () => {
|
||||
const calls: Array<Record<string, unknown>> = []
|
||||
const apiClient = {
|
||||
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitGlobalSessionsByArchived', () => {
|
||||
test('classifies restored (falsy archived) records as active', () => {
|
||||
const { active, archived } = splitGlobalSessionsByArchived([
|
||||
{ id: 'ses_active', time: { created: 1, updated: 20 } },
|
||||
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
|
||||
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
|
||||
] as unknown as Parameters<typeof splitGlobalSessionsByArchived>[0])
|
||||
|
||||
expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored'])
|
||||
expect(archived.map((session) => session.id)).toEqual(['ses_archived'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,11 +84,38 @@ const unwrapSessionList = (
|
||||
*/
|
||||
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
|
||||
|
||||
/**
|
||||
* Split an inclusive (`archived: true`) session page stream into active and
|
||||
* archived buckets. Restored sessions carry `time.archived === 0` (see
|
||||
* `UNARCHIVED_TIMESTAMP` in `sync/session-actions.ts`); the truthiness check
|
||||
* classifies them as active even though the server's own
|
||||
* `time_archived IS NULL` filter would still exclude them, which is why the
|
||||
* global cache must split client-side instead of issuing an
|
||||
* `archived: false` request for its active list.
|
||||
*/
|
||||
export const splitGlobalSessionsByArchived = <T extends GlobalSessionRecord>(
|
||||
sessions: T[],
|
||||
): { active: T[]; archived: T[] } => {
|
||||
const active: T[] = [];
|
||||
const archived: T[] = [];
|
||||
for (const session of sessions) {
|
||||
if (isArchivedSession(session)) archived.push(session);
|
||||
else active.push(session);
|
||||
}
|
||||
return { active, archived };
|
||||
};
|
||||
|
||||
export async function listGlobalSessionPages(
|
||||
apiClient: OpencodeClient,
|
||||
options: {
|
||||
directory?: string;
|
||||
archived: boolean;
|
||||
/**
|
||||
* When `archived` is true, narrow results to records carrying a truthy
|
||||
* `time.archived` (default true). Pass false to receive the inclusive
|
||||
* server response unfiltered, e.g. to split active/archived locally.
|
||||
*/
|
||||
narrowToArchived?: boolean;
|
||||
roots?: boolean;
|
||||
pageSize: number;
|
||||
onPage?: (sessions: GlobalSessionRecord[]) => void;
|
||||
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
|
||||
const all: GlobalSessionRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let cursor: number | undefined;
|
||||
const narrowToArchived = options.narrowToArchived !== false;
|
||||
let operation: string;
|
||||
if (!options.directory) {
|
||||
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
|
||||
operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
|
||||
} else if (options.roots === true) {
|
||||
operation = "bootstrap.sessions.roots";
|
||||
} else if (options.archived) {
|
||||
operation = "bootstrap.sessions.archived";
|
||||
operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
|
||||
} else {
|
||||
operation = "bootstrap.sessions.all";
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let attempts = 0;
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
|
||||
if (!session?.id || seenIds.has(session.id)) continue;
|
||||
seenIds.add(session.id);
|
||||
appended += 1;
|
||||
if (options.archived && !isArchivedSession(session)) continue;
|
||||
if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
|
||||
all.push(session);
|
||||
accepted.push(session);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let activeRequest: Deferred<Session[]>
|
||||
let archivedRequest: Deferred<Session[]>
|
||||
let listRequest: Deferred<Session[]>
|
||||
|
||||
// The store issues one inclusive (`archived: true`) paginated request per
|
||||
// load/refresh scope and splits active/archived client-side, so restored
|
||||
// sessions (`time.archived` falsy-but-present) stay visible in the active
|
||||
// list. The mock serves that single request.
|
||||
const sdk = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async (options: { archived?: boolean }) => ({
|
||||
data: await (options.archived ? archivedRequest.promise : activeRequest.promise),
|
||||
list: async () => ({
|
||||
data: await listRequest.promise,
|
||||
response: { headers: new Headers() },
|
||||
}),
|
||||
},
|
||||
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
|
||||
const session = (id: string, title = id, archived?: number): Session => ({
|
||||
id,
|
||||
title,
|
||||
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) },
|
||||
time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
|
||||
} as Session)
|
||||
|
||||
describe("global session mutation reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
activeRequest = deferred<Session[]>()
|
||||
archivedRequest = deferred<Session[]>()
|
||||
listRequest = deferred<Session[]>()
|
||||
opencodeClient.getSdkClient = () => sdk
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
|
||||
})
|
||||
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("created"))
|
||||
|
||||
activeRequest.resolve([])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
|
||||
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().removeSessions([stale.id])
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([stale])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New")
|
||||
})
|
||||
|
||||
test("uses commit-time state when one side of the load fails", async () => {
|
||||
test("uses commit-time state when the load fails", async () => {
|
||||
const created = session("created")
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(created)
|
||||
|
||||
activeRequest.reject(new Error("unavailable"))
|
||||
archivedRequest.resolve([])
|
||||
listRequest.reject(new Error("unavailable"))
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
|
||||
expect(useGlobalSessionsStore.getState().status).toBe("error")
|
||||
})
|
||||
|
||||
test("splits a restored session into the active list", async () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
|
||||
listRequest.resolve([session("active"), session("archived", "archived", 5), session("restored", "restored", 0)])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["active", "restored"])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions.map((item) => item.id)).toEqual(["archived"])
|
||||
expect(useGlobalSessionsStore.getState().status).toBe("ready")
|
||||
})
|
||||
|
||||
test("does not undo a move while refreshing the source directory", async () => {
|
||||
const source = { ...session("moved"), directory: "/source" } as Session
|
||||
const destination = { ...source, directory: "/destination" } as Session
|
||||
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
|
||||
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
|
||||
useGlobalSessionsStore.getState().upsertSession(destination)
|
||||
|
||||
activeRequest.resolve([source])
|
||||
archivedRequest.resolve([])
|
||||
listRequest.resolve([source])
|
||||
await refreshing
|
||||
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved")
|
||||
})
|
||||
|
||||
test("keeps a restore mutation newer than the directory refresh", async () => {
|
||||
const archived = { ...session("restored", "restored", 5), directory: "/source" } as Session
|
||||
useGlobalSessionsStore.getState().applySnapshot([], [archived])
|
||||
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
|
||||
useGlobalSessionsStore.getState().upsertSession({ ...archived, time: { ...archived.time, archived: 0 } })
|
||||
|
||||
// The server still reports the pre-restore row for this directory.
|
||||
listRequest.resolve([archived])
|
||||
await refreshing
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["restored"])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { listGlobalSessionPages } from '@/stores/globalSessions';
|
||||
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
|
||||
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
|
||||
const fetchDirectoryPages = async (
|
||||
sdk: OpencodeClient,
|
||||
directories: Set<string>,
|
||||
archived: boolean,
|
||||
): Promise<DirectoryPageResult> => {
|
||||
const currentDirectory = normalizePath(opencodeClient.getDirectory());
|
||||
const orderedDirectories = [...directories].sort((left, right) => {
|
||||
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
|
||||
status: 'fulfilled' as const,
|
||||
value: {
|
||||
directory,
|
||||
// One inclusive request per directory: the server has no filter that
|
||||
// returns only active sessions including restored (`time.archived`
|
||||
// falsy-but-present) rows, so fetch everything and split client-side.
|
||||
sessions: await withDirectorySessionRefreshSlot(() => (
|
||||
listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE })
|
||||
listGlobalSessionPages(sdk, { directory, archived: true, narrowToArchived: false, pageSize: PAGE_SIZE })
|
||||
)),
|
||||
},
|
||||
};
|
||||
@@ -526,35 +528,25 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [activeResult, archivedResult] = await Promise.allSettled([
|
||||
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }),
|
||||
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
|
||||
]);
|
||||
|
||||
if (activeResult.status === 'rejected') {
|
||||
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
|
||||
}
|
||||
if (archivedResult.status === 'rejected') {
|
||||
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
||||
}
|
||||
// One inclusive fetch, split client-side. The server's
|
||||
// `time_archived IS NULL` active filter would exclude restored
|
||||
// sessions (`time.archived` falsy-but-present), so an
|
||||
// `archived: false` request cannot produce a truthful active list.
|
||||
const allSessions = await listGlobalSessionPages(sdk, {
|
||||
archived: true,
|
||||
narrowToArchived: false,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (generation !== loadGeneration) {
|
||||
// Runtime switched mid-load: this snapshot belongs to the previous
|
||||
// instance — drop it.
|
||||
return { activeSessions: [], archivedSessions: [] };
|
||||
}
|
||||
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
|
||||
? 'ready'
|
||||
: 'error';
|
||||
const { active, archived } = splitGlobalSessionsByArchived(allSessions);
|
||||
set((state) => {
|
||||
const fetchedActive = activeResult.status === 'fulfilled'
|
||||
? activeResult.value
|
||||
: mergeSessionLists(state.activeSessions, fallbackActive);
|
||||
const fetchedArchived = archivedResult.status === 'fulfilled'
|
||||
? archivedResult.value
|
||||
: state.archivedSessions;
|
||||
const reconciled = overlayMutationsSince(state, fetchedActive, fetchedArchived, baselineRevision);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, status);
|
||||
const reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
|
||||
});
|
||||
const committed = get();
|
||||
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
|
||||
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const generation = loadGeneration;
|
||||
const baselineRevision = get().mutationRevision;
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [active, archived] = await Promise.all([
|
||||
fetchDirectoryPages(sdk, directorySet, false),
|
||||
fetchDirectoryPages(sdk, directorySet, true),
|
||||
]);
|
||||
const fetched = await fetchDirectoryPages(sdk, directorySet);
|
||||
|
||||
if (generation !== loadGeneration) {
|
||||
const state = get();
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
}
|
||||
|
||||
if (active.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
|
||||
}
|
||||
if (archived.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
|
||||
if (fetched.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
|
||||
}
|
||||
|
||||
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
|
||||
|
||||
set((state) => {
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
|
||||
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
|
||||
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
|
||||
nextActiveSessions = state.activeSessions;
|
||||
}
|
||||
|
||||
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories);
|
||||
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived, fetched.directories);
|
||||
if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
|
||||
nextArchivedSessions = state.archivedSessions;
|
||||
}
|
||||
|
||||
@@ -240,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `updateSessionTitle()` -> `upsertSession(result.data)`
|
||||
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
|
||||
- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session
|
||||
- `unarchiveSession()` / `unarchiveSessions()` -> wait for server confirmation, then upsert each restored session
|
||||
- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
### Restore (unarchive) contract
|
||||
|
||||
The OpenCode server cannot clear `time.archived` over HTTP: `session.update`
|
||||
only applies the field when the payload carries a finite number, so an omitted
|
||||
key is a no-op and `null` is silently ignored. Restore therefore writes
|
||||
`time.archived = 0` (`UNARCHIVED_TIMESTAMP` in `session-actions.ts`). Every
|
||||
client-side reader classifies archive state by truthiness of `time.archived`,
|
||||
so `0` reads as active in the UI, the event reducer, and the OpenCode app/TUI.
|
||||
|
||||
The server's `time_archived IS NULL` list filter still excludes such rows, so
|
||||
any query that wants a truthful active list must fetch inclusively
|
||||
(`archived: true`) and split client-side (`splitGlobalSessionsByArchived`).
|
||||
The global sessions store does this for its full and per-directory loads;
|
||||
directory bootstrap keeps using the server filter because live child stores
|
||||
must not hold archived sessions. A restored session re-enters its live
|
||||
directory store through the authoritative `session.updated` event the server
|
||||
publishes for the update; until then it remains fully visible through the
|
||||
global store (sidebar, switcher) and addressable by ID (message loading).
|
||||
|
||||
Archive and delete actions capture the active runtime key when they start and
|
||||
recheck it before every store reconciliation, so a response
|
||||
produced by the previous runtime is rejected instead of mutating the current
|
||||
runtime's live or global session state. A guarded batch stops at the first
|
||||
observed runtime change: sessions the server already confirmed remain archived
|
||||
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed
|
||||
on the captured runtime is returned in `failedIds` so existing partial-failure
|
||||
runtime's live or global session state. Restore follows the same guard: a
|
||||
stale completion returns `false` without touching any store. A guarded batch
|
||||
stops at the first observed runtime change: sessions the server already
|
||||
confirmed remain archived, restored, or deleted and stay in
|
||||
`archivedIds`/`restoredIds`/`deletedIds`, while every ID not confirmed on the
|
||||
captured runtime is returned in `failedIds` so existing partial-failure
|
||||
feedback stays truthful.
|
||||
Callers whose confirmation can span a runtime switch may pass an
|
||||
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default.
|
||||
|
||||
@@ -619,6 +619,116 @@ describe("confirmed session removal", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session restore (unarchive)", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
registeredSessionDirectories.length = 0
|
||||
globalUpsertedSessions.length = 0
|
||||
sessionUpdateResult = {}
|
||||
beforeSessionUpdateResolve = null
|
||||
})
|
||||
|
||||
test("does not restore locally until the server returns the restored session", async () => {
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("sends the archive-clearing sentinel and upserts the restored session after confirmation", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(true)
|
||||
// The server cannot clear time.archived over HTTP, so the action must
|
||||
// write the falsy sentinel rather than omitting the field.
|
||||
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([{
|
||||
method: "session.update",
|
||||
params: { sessionID: "session-a", time: { archived: 0 }, directory: "/test/project" },
|
||||
}])
|
||||
expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(0)
|
||||
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/test/project" }])
|
||||
})
|
||||
|
||||
test("fails when the server keeps the session archived", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
// A silent server-side no-op must surface as a failure, not a success toast.
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("rejects a restore response that arrives after a runtime switch", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-a.test", runtimeKey: "restore-runtime-a" })
|
||||
beforeSessionUpdateResolve = () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-b.test", runtimeKey: "restore-runtime-b" })
|
||||
}
|
||||
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
expect(await unarchiveSession("session-a")).toBe(false)
|
||||
expect(getRuntimeKey()).toBe("restore-runtime-b")
|
||||
// The stale response must not reconcile the runtime the user switched to.
|
||||
expect(globalUpsertedSessions).toEqual([])
|
||||
expect(registeredSessionDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => {
|
||||
sessionUpdateResult = {
|
||||
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
||||
}
|
||||
const source = createStore({}, {
|
||||
session: [],
|
||||
})
|
||||
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-a.test", runtimeKey: "restore-batch-a" })
|
||||
beforeSessionUpdateResolve = (sessionId) => {
|
||||
if (sessionId === "session-b") {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-b.test", runtimeKey: "restore-batch-b" })
|
||||
}
|
||||
}
|
||||
const { unarchiveSessions, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
||||
|
||||
const result = await unarchiveSessions(["session-a", "session-b", "session-c"])
|
||||
|
||||
// session-a was confirmed before the switch and stays restored; session-b's
|
||||
// response is stale and session-c is never attempted, so both are reported
|
||||
// as failures instead of being silently dropped.
|
||||
expect(result).toEqual({ restoredIds: ["session-a"], failedIds: ["session-b", "session-c"] })
|
||||
expect(globalUpsertedSessions).toHaveLength(1)
|
||||
// session-c must not reach the SDK after the runtime changed.
|
||||
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
||||
.toEqual(["session-a", "session-b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchMessagesForSession startup race", () => {
|
||||
test("does not reject before sync action refs are initialized", async () => {
|
||||
const { fetchMessagesForSession } = await import("./session-actions")
|
||||
|
||||
@@ -1014,6 +1014,92 @@ export async function archiveSessions(
|
||||
return { archivedIds, failedIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel written to `time.archived` when restoring a session.
|
||||
*
|
||||
* The OpenCode server has no HTTP path to clear `time.archived` back to NULL:
|
||||
* `session.update` only applies the field when the payload carries a finite
|
||||
* number (`archived !== undefined`), so omitting the key is a no-op and `null`
|
||||
* is silently ignored. Writing `0` is the only value that makes every reader
|
||||
* treat the session as active again: the UI, the event reducer, and the
|
||||
* OpenCode app/TUI all classify archive state by truthiness of
|
||||
* `time.archived`, and `0` is falsy. The one place that still excludes such a
|
||||
* session is the server's own `time_archived IS NULL` list filter, so the
|
||||
* global session cache loads with the inclusive `archived` flag and splits
|
||||
* client-side instead of relying on that filter (see
|
||||
* `useGlobalSessionsStore.loadSessions`).
|
||||
*/
|
||||
const UNARCHIVED_TIMESTAMP = 0
|
||||
|
||||
/**
|
||||
* Restore one archived session back to the active list.
|
||||
*
|
||||
* Same contract as `archiveSession`: waits for server confirmation before
|
||||
* reconciling stores, and rejects stale runtimes so a response produced by a
|
||||
* previous runtime cannot mutate the current runtime's state. The global
|
||||
* session cache is updated directly (the sidebar reads active/archived
|
||||
* buckets from it); the live directory store is re-populated by the
|
||||
* authoritative `session.updated` event the server publishes for the update.
|
||||
*/
|
||||
export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
try {
|
||||
const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return false
|
||||
if (!restored) {
|
||||
throw new Error("session.update failed: server did not return the restored session")
|
||||
}
|
||||
if (restored.time?.archived) {
|
||||
throw new Error("session.update failed: server kept the session archived")
|
||||
}
|
||||
useGlobalSessionsStore.getState().upsertSession(restored)
|
||||
if (sessionDirectory) registerSessionDirectory(sessionId, sessionDirectory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] unarchiveSession failed", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type UnarchiveSessionsOptions = {
|
||||
/**
|
||||
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
||||
* stops as soon as the active runtime differs.
|
||||
*/
|
||||
expectedRuntimeKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore several archived sessions sequentially, preserving partial results.
|
||||
*
|
||||
* One failed session never blocks or erases the others: it is reported in
|
||||
* `failedIds` while the remaining IDs are still attempted. When
|
||||
* `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the
|
||||
* already-confirmed sessions stay in `restoredIds` and every ID that was not
|
||||
* confirmed on the captured runtime is reported in `failedIds`, so callers keep
|
||||
* showing truthful partial-failure feedback.
|
||||
*/
|
||||
export async function unarchiveSessions(
|
||||
ids: string[],
|
||||
options?: UnarchiveSessionsOptions,
|
||||
): Promise<{ restoredIds: string[]; failedIds: string[] }> {
|
||||
const restoredIds: string[] = []
|
||||
const failedIds: string[] = []
|
||||
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
||||
|
||||
for (const [index, id] of ids.entries()) {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) {
|
||||
failedIds.push(...ids.slice(index))
|
||||
break
|
||||
}
|
||||
if (await unarchiveSession(id, expectedRuntimeKey)) restoredIds.push(id)
|
||||
else failedIds.push(id)
|
||||
}
|
||||
|
||||
return { restoredIds, failedIds }
|
||||
}
|
||||
|
||||
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
||||
|
||||
@@ -483,6 +483,15 @@ describe('archiveSessions option forwarding', () => {
|
||||
expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] });
|
||||
expect(updateSessionCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('unarchiveSessions honors expectedRuntimeKey instead of discarding the options object', async () => {
|
||||
const result = await useSessionUIStore.getState().unarchiveSessions(['session-x', 'session-y'], {
|
||||
expectedRuntimeKey: 'runtime-that-is-not-active',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ restoredIds: [], failedIds: ['session-x', 'session-y'] });
|
||||
expect(updateSessionCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSessions option forwarding', () => {
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
deleteSessions as deleteSessionsAction,
|
||||
archiveSession as archiveSessionAction,
|
||||
archiveSessions as archiveSessionsAction,
|
||||
unarchiveSession as unarchiveSessionAction,
|
||||
unarchiveSessions as unarchiveSessionsAction,
|
||||
updateSessionTitle as updateSessionTitleAction,
|
||||
shareSession as shareSessionAction,
|
||||
unshareSession as unshareSessionAction,
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
type ArchiveSessionsOptions,
|
||||
type DeleteSessionOptions,
|
||||
type DeleteSessionsOptions,
|
||||
type UnarchiveSessionsOptions,
|
||||
} from "./session-actions"
|
||||
import { useInputStore, type SyntheticContextPart } from "./input-store"
|
||||
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
|
||||
@@ -335,6 +338,8 @@ export type SessionUIState = {
|
||||
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
|
||||
archiveSession: (id: string) => Promise<boolean>
|
||||
archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }>
|
||||
unarchiveSession: (id: string) => Promise<boolean>
|
||||
unarchiveSessions: (ids: string[], options?: UnarchiveSessionsOptions) => Promise<{ restoredIds: string[]; failedIds: string[] }>
|
||||
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
|
||||
shareSession: (sessionId: string) => Promise<Session | null>
|
||||
unshareSession: (sessionId: string) => Promise<Session | null>
|
||||
@@ -1423,6 +1428,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
archiveSessions: (ids, options) => archiveSessionsAction(ids, options),
|
||||
|
||||
unarchiveSession: (id) => unarchiveSessionAction(id),
|
||||
|
||||
unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateSessionTitle — calls SDK, SSE event updates child store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user