Merge pull request #2616 from makeittech/feat/restore-archived-sessions-2346

feat: add restore/unarchive for archived sessions
This commit is contained in:
Serhii Dziupin
2026-08-04 13:41:00 +03:00
committed by GitHub
28 changed files with 556 additions and 69 deletions
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const deleteSessions = useSessionUIStore((state) => state.deleteSessions); const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const archiveSession = useSessionUIStore((state) => state.archiveSession); const archiveSession = useSessionUIStore((state) => state.archiveSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions); const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
const { const {
copiedSessionId, copiedSessionId,
@@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleCopySessionId, handleCopySessionId,
handleUnshareSession, handleUnshareSession,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
confirmDeleteSession, confirmDeleteSession,
} = useSessionActions({ } = useSessionActions({
mobileVariant, mobileVariant,
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
deleteSessions, deleteSessions,
archiveSession, archiveSession,
archiveSessions, archiveSessions,
unarchiveSession,
childrenMap, childrenMap,
showDeletionDialog, showDeletionDialog,
setDeleteSessionConfirm, setDeleteSessionConfirm,
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId); const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession); const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession); const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename); const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => { const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
@@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
createFolderAndStartRename={stableCreateFolderAndStartRename} createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab} openContextPanelTab={openContextPanelTab}
handleDeleteSession={stableHandleDeleteSession} handleDeleteSession={stableHandleDeleteSession}
handleRestoreSession={stableHandleRestoreSession}
mobileVariant={mobileVariant} mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions} alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleBulkCreateFolderAndMove, handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder, handleBulkRemoveFromFolder,
handleBulkDelete, handleBulkDelete,
handleBulkRestore,
confirmBulkDelete, confirmBulkDelete,
} = useSidebarBulkActions({ } = useSidebarBulkActions({
isInlineEditing, isInlineEditing,
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
removeSessionsFromFolders, removeSessionsFromFolders,
createFolderAndStartRename, createFolderAndStartRename,
archiveSessions, archiveSessions,
unarchiveSessions,
deleteSessions, deleteSessions,
setBulkDeleteConfirm, setBulkDeleteConfirm,
}); });
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
onCreateFolderAndMove={handleBulkCreateFolderAndMove} onCreateFolderAndMove={handleBulkCreateFolderAndMove}
onRemoveFromFolder={handleBulkRemoveFromFolder} onRemoveFromFolder={handleBulkRemoveFromFolder}
canRemoveFromFolder={bulkCanRemoveFromFolder} canRemoveFromFolder={bulkCanRemoveFromFolder}
onRestore={handleBulkRestore}
onDelete={handleBulkDelete} onDelete={handleBulkDelete}
onDone={handleExitSelectionMode} onDone={handleExitSelectionMode}
/> />
@@ -21,6 +21,7 @@ type Props = {
onCreateFolderAndMove: () => void; onCreateFolderAndMove: () => void;
onRemoveFromFolder: () => void; onRemoveFromFolder: () => void;
canRemoveFromFolder: boolean; canRemoveFromFolder: boolean;
onRestore: () => void;
onDelete: () => void; onDelete: () => void;
onDone: () => void; onDone: () => void;
}; };
@@ -34,6 +35,7 @@ export const BulkActionBar: React.FC<Props> = ({
onCreateFolderAndMove, onCreateFolderAndMove,
onRemoveFromFolder, onRemoveFromFolder,
canRemoveFromFolder, canRemoveFromFolder,
onRestore,
onDelete, onDelete,
onDone, onDone,
}) => { }) => {
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
</DropdownMenu> </DropdownMenu>
) : null} ) : 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <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. - 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. - 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). - 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. - 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. - 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. - 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; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void; 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; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
handleRestoreSession: (session: Session) => void;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
renderSessionNode: ( renderSessionNode: (
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
createFolderAndStartRename, createFolderAndStartRename,
openContextPanelTab, openContextPanelTab,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
mobileVariant, mobileVariant,
alwaysShowActions, alwaysShowActions,
renderSessionNode, renderSessionNode,
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
{t('sessions.sidebar.bulkActions.archive')} {t('sessions.sidebar.bulkActions.archive')}
</Item> </Item>
) : null} ) : 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 })}> <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" /> <Icon name="delete-bin" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.bulkActions.delete')} {t('sessions.sidebar.bulkActions.delete')}
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
&& prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.openContextPanelTab === next.openContextPanelTab && prev.openContextPanelTab === next.openContextPanelTab
&& prev.handleDeleteSession === next.handleDeleteSession && prev.handleDeleteSession === next.handleDeleteSession
&& prev.handleRestoreSession === next.handleRestoreSession
&& prev.renderSessionNode === next.renderSessionNode; && prev.renderSessionNode === next.renderSessionNode;
}; };
@@ -40,6 +40,7 @@ type Args = {
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
archiveSession: (id: string) => Promise<boolean>; archiveSession: (id: string) => Promise<boolean>;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSession: (id: string) => Promise<boolean>;
childrenMap: Map<string, Session[]>; childrenMap: Map<string, Session[]>;
showDeletionDialog: boolean; showDeletionDialog: boolean;
setDeleteSessionConfirm: DeleteSessionConfirmSetter; setDeleteSessionConfirm: DeleteSessionConfirmSetter;
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
await executeDeleteSession(session, { archivedBucket }, { descendantIds }); await executeDeleteSession(session, { archivedBucket }, { descendantIds });
}, [args, executeDeleteSession]); }, [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 { return {
copiedSessionId, copiedSessionId,
handleSessionSelect, handleSessionSelect,
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
handleCopySessionId, handleCopySessionId,
handleUnshareSession, handleUnshareSession,
handleDeleteSession, handleDeleteSession,
handleRestoreSession,
confirmDeleteSession, confirmDeleteSession,
}; };
}; };
@@ -18,6 +18,7 @@ type Args = {
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void; removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{ setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number; sessionCount: number;
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
removeSessionsFromFolders, removeSessionsFromFolders,
createFolderAndStartRename, createFolderAndStartRename,
archiveSessions, archiveSessions,
unarchiveSessions,
deleteSessions, deleteSessions,
setBulkDeleteConfirm, setBulkDeleteConfirm,
} = args; } = args;
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived }); setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]); }, [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 () => { const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null); setBulkDeleteConfirm(null);
await executeBulkDelete(); await executeBulkDelete();
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
handleBulkCreateFolderAndMove, handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder, handleBulkRemoveFromFolder,
handleBulkDelete, handleBulkDelete,
handleBulkRestore,
confirmBulkDelete, confirmBulkDelete,
}; };
}; };
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { cn, formatDirectoryName } from '@/lib/utils'; import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
const setOpen = useUIStore((state) => state.setArchivePageOpen); const setOpen = useUIStore((state) => state.setArchivePageOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : [])); const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
setOpen(false); setOpen(false);
}, [setActiveMainTab, setCurrentSession, setOpen]); }, [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; if (!open) return null;
const renderDirectoryItem = ( const renderDirectoryItem = (
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
return ( return (
<div <div
key={session.id} 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)} onClick={() => openSession(session)}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75"> <span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())} {formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
</span> </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 <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
+8
View File
@@ -426,6 +426,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert', 'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen', '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.none': 'Noch keine Ordner',
'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...', 'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...',
'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen', '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.delete.error': 'Fehler beim Löschen der Sitzung',
'sessions.sidebar.session.archive.success': 'Sitzung archiviert', 'sessions.sidebar.session.archive.success': 'Sitzung archiviert',
'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung', '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.checksPassed': '{success}/{total} Checks bestanden',
'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen', 'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen',
'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend', '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.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen',
'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen', 'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen',
'sessions.archivePage.deleteSessionAria': '{title} löschen', 'sessions.archivePage.deleteSessionAria': '{title} löschen',
'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen',
'header.sessionActions.openAria': 'Sitzungsaktionen öffnen', 'header.sessionActions.openAria': 'Sitzungsaktionen öffnen',
'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren', 'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren',
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', 'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
+8
View File
@@ -449,6 +449,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project', 'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}', 'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
'sessions.archivePage.deleteSessionAria': 'Delete {title}', 'sessions.archivePage.deleteSessionAria': 'Delete {title}',
'sessions.archivePage.restoreSessionAria': 'Restore {title}',
'sessions.switcher.openAria': 'Open session switcher', 'sessions.switcher.openAria': 'Open session switcher',
'sessions.switcher.empty': 'No recent sessions', 'sessions.switcher.empty': 'No recent sessions',
'sessions.switcher.draftTitle': 'New session', 'sessions.switcher.draftTitle': 'New session',
@@ -470,6 +471,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions', 'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session', 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session',
'sessions.sidebar.bulkActions.failedArchivePlural': 'Failed to archive {count} sessions', '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.none': 'No folders yet',
'sessions.sidebar.folders.newFolderEllipsis': 'New folder...', 'sessions.sidebar.folders.newFolderEllipsis': 'New folder...',
'sessions.sidebar.folders.removeFromFolder': 'Remove from 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.delete.error': 'Failed to delete session',
'sessions.sidebar.session.archive.success': 'Session archived', 'sessions.sidebar.session.archive.success': 'Session archived',
'sessions.sidebar.session.archive.error': 'Failed to archive session', '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.checksPassed': '{success}/{total} checks passed',
'sessions.sidebar.group.pr.failingCount': '{count} failing', 'sessions.sidebar.group.pr.failingCount': '{count} failing',
'sessions.sidebar.group.pr.pendingCount': '{count} pending', 'sessions.sidebar.group.pr.pendingCount': '{count} pending',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto", "sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}", "sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Eliminar {title}", "sessions.archivePage.deleteSessionAria": "Eliminar {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir selector de sesiones", "sessions.switcher.openAria": "Abrir selector de sesiones",
"sessions.switcher.empty": "No hay sesiones recientes", "sessions.switcher.empty": "No hay sesiones recientes",
"sessions.switcher.draftTitle": "Nueva sesión", "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.archivedPlural": "Se archivaron {count} sesiones",
"sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión", "sessions.sidebar.bulkActions.failedArchiveSingle": "No se pudo archivar {count} sesión",
"sessions.sidebar.bulkActions.failedArchivePlural": "No se pudo archivar {count} sesiones", "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.none": "No hay carpetas aún",
"sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...", "sessions.sidebar.folders.newFolderEllipsis": "Nueva carpeta...",
"sessions.sidebar.folders.removeFromFolder": "Quitar de 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.delete.error": "No se pudo eliminar la sesión",
"sessions.sidebar.session.archive.success": "Sesión archivada", "sessions.sidebar.session.archive.success": "Sesión archivada",
"sessions.sidebar.session.archive.error": "No se pudo archivar la sesión", "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.checksPassed": "{success}/{total} comprobaciones aprobadas",
"sessions.sidebar.group.pr.failingCount": "{count} con fallos", "sessions.sidebar.group.pr.failingCount": "{count} con fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendientes", "sessions.sidebar.group.pr.pendingCount": "{count} pendientes",
+8
View File
@@ -285,6 +285,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet', '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.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}', 'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
'sessions.archivePage.restoreSessionAria': 'Restaurer {title}',
'sessions.switcher.openAria': 'Sélecteur de session ouvert', 'sessions.switcher.openAria': 'Sélecteur de session ouvert',
'sessions.switcher.empty': 'Aucune session récente', 'sessions.switcher.empty': 'Aucune session récente',
'sessions.switcher.draftTitle': 'Nouvelle session', 'sessions.switcher.draftTitle': 'Nouvelle session',
@@ -306,6 +307,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées', 'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Échec de l\'archivage de la session {count}', '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.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.none': 'Aucun dossier pour l\'instant',
'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...', 'sessions.sidebar.folders.newFolderEllipsis': 'Nouveau dossier...',
'sessions.sidebar.folders.removeFromFolder': 'Supprimer du 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.delete.error': 'Échec de la suppression de la session',
'sessions.sidebar.session.archive.success': 'Session archivée', 'sessions.sidebar.session.archive.success': 'Session archivée',
'sessions.sidebar.session.archive.error': 'Échec de l\'archivage de la session', '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.checksPassed': 'Contrôles {success}/{total} réussis',
'sessions.sidebar.group.pr.failingCount': 'Échec de {count}', 'sessions.sidebar.group.pr.failingCount': 'Échec de {count}',
'sessions.sidebar.group.pr.pendingCount': '{count} en attente', 'sessions.sidebar.group.pr.pendingCount': '{count} en attente',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除', 'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteSessionAria': '{title} を削除', 'sessions.archivePage.deleteSessionAria': '{title} を削除',
'sessions.archivePage.restoreSessionAria': '{title} を復元',
'sessions.switcher.openAria': 'セッションスイッチャーを開く', 'sessions.switcher.openAria': 'セッションスイッチャーを開く',
'sessions.switcher.empty': '最近のセッションはありません', 'sessions.switcher.empty': '最近のセッションはありません',
'sessions.switcher.draftTitle': '新しいセッション', 'sessions.switcher.draftTitle': '新しいセッション',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました', 'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました',
'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました', 'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました',
'sessions.sidebar.bulkActions.failedArchivePlural': '{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.none': 'まだフォルダがありません',
'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...', 'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...',
'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除', 'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました', 'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました',
'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました', 'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました',
'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました', '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.checksPassed': '{success}/{total}のチェックに合格',
'sessions.sidebar.group.pr.failingCount': '{count}件失敗', 'sessions.sidebar.group.pr.failingCount': '{count}件失敗',
'sessions.sidebar.group.pr.pendingCount': '{count}件保留中', 'sessions.sidebar.group.pr.pendingCount': '{count}件保留中',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제', 'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteSessionAria': '{title} 삭제', 'sessions.archivePage.deleteSessionAria': '{title} 삭제',
'sessions.archivePage.restoreSessionAria': '{title} 복원',
'sessions.switcher.openAria': '세션 전환기 열기', 'sessions.switcher.openAria': '세션 전환기 열기',
'sessions.switcher.empty': '최근 세션 없음', 'sessions.switcher.empty': '최근 세션 없음',
'sessions.switcher.draftTitle': '새 세션', 'sessions.switcher.draftTitle': '새 세션',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨', 'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨',
'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패', 'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {count}개 보관 실패',
'sessions.sidebar.bulkActions.failedArchivePlural': '세션 {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.none': '아직 폴더 없음',
'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…', 'sessions.sidebar.folders.newFolderEllipsis': '새 폴더…',
'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거', 'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '세션 삭제 실패', 'sessions.sidebar.session.delete.error': '세션 삭제 실패',
'sessions.sidebar.session.archive.success': '세션 보관됨', 'sessions.sidebar.session.archive.success': '세션 보관됨',
'sessions.sidebar.session.archive.error': '세션 보관 실패', '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.checksPassed': '검사 통과: {success}/{total}',
'sessions.sidebar.group.pr.failingCount': '실패 {count}개', 'sessions.sidebar.group.pr.failingCount': '실패 {count}개',
'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중', 'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중',
+8
View File
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu', 'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}', 'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
'sessions.archivePage.deleteSessionAria': 'Usuń {title}', 'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
'sessions.archivePage.restoreSessionAria': 'Przywróć {title}',
'sessions.switcher.openAria': 'Otwórz przełącznik sesji', 'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
'sessions.switcher.empty': 'Brak ostatnich sesji', 'sessions.switcher.empty': 'Brak ostatnich sesji',
'sessions.switcher.draftTitle': 'Nowa sesja', '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.archivedPlural': 'Zarchiwizowano {count} sesji',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Nie udało się zarchiwizować {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.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.title': 'Zaplanowane zadania',
'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.', 'sessions.scheduledTasks.dialog.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.',
'sessions.scheduledTasks.dialog.project.label': 'Projekt', '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.delete.error': 'Nie udało się usunąć sesji',
'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana', 'sessions.sidebar.session.archive.success': 'Sesja zarchiwizowana',
'sessions.sidebar.session.archive.error': 'Nie udało się zarchiwizować sesji', '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.checksPassed': '{success}/{total} testów przeszło',
'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń', 'sessions.sidebar.group.pr.failingCount': '{count} niepowodzeń',
'sessions.sidebar.group.pr.pendingCount': '{count} oczekujących', '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.deleteProject": "Excluir todas as sessões arquivadas deste projeto",
"sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}", "sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Excluir {title}", "sessions.archivePage.deleteSessionAria": "Excluir {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir seletor de sessões", "sessions.switcher.openAria": "Abrir seletor de sessões",
"sessions.switcher.empty": "Nenhuma sessão recente", "sessions.switcher.empty": "Nenhuma sessão recente",
"sessions.switcher.draftTitle": "Nova sessão", "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.archivedPlural": "{count} sessões arquivadas",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Não foi possível arquivar {count} sessão", "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.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.none": "Não há pastas ainda",
"sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...", "sessions.sidebar.folders.newFolderEllipsis": "Nova pasta...",
"sessions.sidebar.folders.removeFromFolder": "Remover da 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.delete.error": "Não foi possível excluir a sessão",
"sessions.sidebar.session.archive.success": "Sessão archivada", "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.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.checksPassed": "{success}/{total} checks pasadas",
"sessions.sidebar.group.pr.failingCount": "{count} com fallos", "sessions.sidebar.group.pr.failingCount": "{count} com fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendentes", "sessions.sidebar.group.pr.pendingCount": "{count} pendentes",
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту", "sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}", "sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
"sessions.archivePage.deleteSessionAria": "Видалити {title}", "sessions.archivePage.deleteSessionAria": "Видалити {title}",
"sessions.archivePage.restoreSessionAria": "Відновити {title}",
"sessions.switcher.openAria": "Відкрити перемикач сесій", "sessions.switcher.openAria": "Відкрити перемикач сесій",
"sessions.switcher.empty": "Немає недавніх сесій", "sessions.switcher.empty": "Немає недавніх сесій",
"sessions.switcher.draftTitle": "Нова сесія", "sessions.switcher.draftTitle": "Нова сесія",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}", "sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}", "sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {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.none": "Папок ще немає",
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...", "sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
"sessions.sidebar.folders.removeFromFolder": "Видалити з папки", "sessions.sidebar.folders.removeFromFolder": "Видалити з папки",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "Не вдалося видалити сесію", "sessions.sidebar.session.delete.error": "Не вдалося видалити сесію",
"sessions.sidebar.session.archive.success": "Сесію заархівовано", "sessions.sidebar.session.archive.success": "Сесію заархівовано",
"sessions.sidebar.session.archive.error": "Не вдалося заархівувати сесію", "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.checksPassed": "Перевірки {success}/{total} пройдено",
"sessions.sidebar.group.pr.failingCount": "{count} з помилкою", "sessions.sidebar.group.pr.failingCount": "{count} з помилкою",
"sessions.sidebar.group.pr.pendingCount": "{count} очікує", "sessions.sidebar.group.pr.pendingCount": "{count} очікує",
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话', 'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话',
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话', 'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
'sessions.archivePage.deleteSessionAria': '删除 {title}', 'sessions.archivePage.deleteSessionAria': '删除 {title}',
'sessions.archivePage.restoreSessionAria': '还原 {title}',
'sessions.switcher.openAria': '打开会话切换器', 'sessions.switcher.openAria': '打开会话切换器',
'sessions.switcher.empty': '没有最近会话', 'sessions.switcher.empty': '没有最近会话',
'sessions.switcher.draftTitle': '新会话', 'sessions.switcher.draftTitle': '新会话',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话', 'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话',
'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败', 'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {count} 个会话失败',
'sessions.sidebar.bulkActions.failedArchivePlural': '归档 {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.none': '暂无文件夹',
'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...', 'sessions.sidebar.folders.newFolderEllipsis': '新建文件夹...',
'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除', 'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '删除会话失败', 'sessions.sidebar.session.delete.error': '删除会话失败',
'sessions.sidebar.session.archive.success': '会话已归档', 'sessions.sidebar.session.archive.success': '会话已归档',
'sessions.sidebar.session.archive.error': '归档会话失败', '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.checksPassed': '{success}/{total} 项检查已通过',
'sessions.sidebar.group.pr.failingCount': '{count} 项失败', 'sessions.sidebar.group.pr.failingCount': '{count} 项失败',
'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中', 'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中',
@@ -463,6 +463,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段', 'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段',
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段', 'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
'sessions.archivePage.deleteSessionAria': '刪除 {title}', 'sessions.archivePage.deleteSessionAria': '刪除 {title}',
'sessions.archivePage.restoreSessionAria': '還原 {title}',
'sessions.switcher.openAria': '開啟會話切換器', 'sessions.switcher.openAria': '開啟會話切換器',
'sessions.switcher.empty': '沒有最近會話', 'sessions.switcher.empty': '沒有最近會話',
'sessions.switcher.draftTitle': '新會話', 'sessions.switcher.draftTitle': '新會話',
@@ -484,6 +485,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話', 'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話',
'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗', 'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {count} 個會話失敗',
'sessions.sidebar.bulkActions.failedArchivePlural': '封存 {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.none': '暫無資料夾',
'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...', 'sessions.sidebar.folders.newFolderEllipsis': '新增資料夾...',
'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除', 'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除',
@@ -587,6 +593,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '刪除會話失敗', 'sessions.sidebar.session.delete.error': '刪除會話失敗',
'sessions.sidebar.session.archive.success': '會話已封存', 'sessions.sidebar.session.archive.success': '會話已封存',
'sessions.sidebar.session.archive.error': '封存會話失敗', '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.checksPassed': '{success}/{total} 項檢查已通過',
'sessions.sidebar.group.pr.failingCount': '{count} 項失敗', 'sessions.sidebar.group.pr.failingCount': '{count} 項失敗',
'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中', 'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中',
+2 -2
View File
@@ -59,8 +59,8 @@ User-visible session ordering is also not owned by the global cache array order.
Global refresh rules: 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. - 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 is bounded to two requests across callers and prioritizes the current directory. - 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. - 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. - 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. - Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
+35 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages } from './globalSessions' import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('listGlobalSessionPages', () => { describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => { 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']) 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 () => { test('keeps paginating archived pages that are full of non-archived records', async () => {
const calls: Array<Record<string, unknown>> = [] const calls: Array<Record<string, unknown>> = []
const apiClient = { const apiClient = {
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_1']) 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'])
})
})
+31 -4
View File
@@ -84,11 +84,38 @@ const unwrapSessionList = (
*/ */
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived); 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( export async function listGlobalSessionPages(
apiClient: OpencodeClient, apiClient: OpencodeClient,
options: { options: {
directory?: string; directory?: string;
archived: boolean; 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; roots?: boolean;
pageSize: number; pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void; onPage?: (sessions: GlobalSessionRecord[]) => void;
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
const all: GlobalSessionRecord[] = []; const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>(); const seenIds = new Set<string>();
let cursor: number | undefined; let cursor: number | undefined;
const narrowToArchived = options.narrowToArchived !== false;
let operation: string; let operation: string;
if (!options.directory) { if (!options.directory) {
operation = `global-sessions.${options.archived ? "archived" : "active"}`; operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
} else if (options.roots === true) { } else if (options.roots === true) {
operation = "bootstrap.sessions.roots"; operation = "bootstrap.sessions.roots";
} else if (options.archived) { } else if (options.archived) {
operation = "bootstrap.sessions.archived"; operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
} else { } else {
operation = "bootstrap.sessions.all"; operation = "bootstrap.sessions.all";
} }
while (true) { while (true) {
let attempts = 0; let attempts = 0;
const finishPerformanceEvent = startSessionLoadPerformanceEvent({ const finishPerformanceEvent = startSessionLoadPerformanceEvent({
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
if (!session?.id || seenIds.has(session.id)) continue; if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id); seenIds.add(session.id);
appended += 1; appended += 1;
if (options.archived && !isArchivedSession(session)) continue; if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
all.push(session); all.push(session);
accepted.push(session); accepted.push(session);
} }
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
return { promise, resolve, reject } return { promise, resolve, reject }
} }
let activeRequest: Deferred<Session[]> let listRequest: Deferred<Session[]>
let archivedRequest: 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 = { const sdk = {
experimental: { experimental: {
session: { session: {
list: async (options: { archived?: boolean }) => ({ list: async () => ({
data: await (options.archived ? archivedRequest.promise : activeRequest.promise), data: await listRequest.promise,
response: { headers: new Headers() }, response: { headers: new Headers() },
}), }),
}, },
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
const session = (id: string, title = id, archived?: number): Session => ({ const session = (id: string, title = id, archived?: number): Session => ({
id, id,
title, title,
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) }, time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
} as Session) } as Session)
describe("global session mutation reconciliation", () => { describe("global session mutation reconciliation", () => {
beforeEach(() => { beforeEach(() => {
activeRequest = deferred<Session[]>() listRequest = deferred<Session[]>()
archivedRequest = deferred<Session[]>()
opencodeClient.getSdkClient = () => sdk opencodeClient.getSdkClient = () => sdk
useGlobalSessionsStore.getState().resetForRuntimeSwitch() useGlobalSessionsStore.getState().resetForRuntimeSwitch()
}) })
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("created")) useGlobalSessionsStore.getState().upsertSession(session("created"))
activeRequest.resolve([]) listRequest.resolve([])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"]) expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().removeSessions([stale.id]) useGlobalSessionsStore.getState().removeSessions([stale.id])
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10) useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("updated", "New")) useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
activeRequest.resolve([stale]) listRequest.resolve([stale])
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New") 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 created = session("created")
const loading = useGlobalSessionsStore.getState().loadSessions() const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(created) useGlobalSessionsStore.getState().upsertSession(created)
activeRequest.reject(new Error("unavailable")) listRequest.reject(new Error("unavailable"))
archivedRequest.resolve([])
await loading await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created]) expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
expect(useGlobalSessionsStore.getState().status).toBe("error") 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 () => { test("does not undo a move while refreshing the source directory", async () => {
const source = { ...session("moved"), directory: "/source" } as Session const source = { ...session("moved"), directory: "/source" } as Session
const destination = { ...source, directory: "/destination" } as Session const destination = { ...source, directory: "/destination" } as Session
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"]) const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession(destination) useGlobalSessionsStore.getState().upsertSession(destination)
activeRequest.resolve([source]) listRequest.resolve([source])
archivedRequest.resolve([])
await refreshing await refreshing
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined) expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved") 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 { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client'; 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 { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization'; import { normalizePath } from '@/lib/pathNormalization';
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
const fetchDirectoryPages = async ( const fetchDirectoryPages = async (
sdk: OpencodeClient, sdk: OpencodeClient,
directories: Set<string>, directories: Set<string>,
archived: boolean,
): Promise<DirectoryPageResult> => { ): Promise<DirectoryPageResult> => {
const currentDirectory = normalizePath(opencodeClient.getDirectory()); const currentDirectory = normalizePath(opencodeClient.getDirectory());
const orderedDirectories = [...directories].sort((left, right) => { const orderedDirectories = [...directories].sort((left, right) => {
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
status: 'fulfilled' as const, status: 'fulfilled' as const,
value: { value: {
directory, 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(() => ( 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 () => { const loadPromise = (async () => {
try { try {
const sdk = opencodeClient.getSdkClient(); const sdk = opencodeClient.getSdkClient();
const [activeResult, archivedResult] = await Promise.allSettled([ // One inclusive fetch, split client-side. The server's
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), // `time_archived IS NULL` active filter would exclude restored
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), // sessions (`time.archived` falsy-but-present), so an
]); // `archived: false` request cannot produce a truthful active list.
const allSessions = await listGlobalSessionPages(sdk, {
if (activeResult.status === 'rejected') { archived: true,
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason); narrowToArchived: false,
} pageSize: PAGE_SIZE,
if (archivedResult.status === 'rejected') { });
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
if (generation !== loadGeneration) { if (generation !== loadGeneration) {
// Runtime switched mid-load: this snapshot belongs to the previous // Runtime switched mid-load: this snapshot belongs to the previous
// instance — drop it. // instance — drop it.
return { activeSessions: [], archivedSessions: [] }; return { activeSessions: [], archivedSessions: [] };
} }
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled' const { active, archived } = splitGlobalSessionsByArchived(allSessions);
? 'ready'
: 'error';
set((state) => { set((state) => {
const fetchedActive = activeResult.status === 'fulfilled' const reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
? activeResult.value return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
: 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 committed = get(); const committed = get();
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions }; return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const generation = loadGeneration; const generation = loadGeneration;
const baselineRevision = get().mutationRevision; const baselineRevision = get().mutationRevision;
const sdk = opencodeClient.getSdkClient(); const sdk = opencodeClient.getSdkClient();
const [active, archived] = await Promise.all([ const fetched = await fetchDirectoryPages(sdk, directorySet);
fetchDirectoryPages(sdk, directorySet, false),
fetchDirectoryPages(sdk, directorySet, true),
]);
if (generation !== loadGeneration) { if (generation !== loadGeneration) {
const state = get(); const state = get();
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
} }
if (active.errors.length > 0) { if (fetched.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]); console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
}
if (archived.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
} }
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
set((state) => { set((state) => {
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories); let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive); nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
if (sameSessionList(state.activeSessions, nextActiveSessions)) { if (sameSessionList(state.activeSessions, nextActiveSessions)) {
nextActiveSessions = state.activeSessions; 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)) { if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
nextArchivedSessions = state.archivedSessions; nextArchivedSessions = state.archivedSessions;
} }
+26 -4
View File
@@ -240,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`:
- `updateSessionTitle()` -> `upsertSession(result.data)` - `updateSessionTitle()` -> `upsertSession(result.data)`
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session - `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 - `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 - `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 Archive and delete actions capture the active runtime key when they start and
recheck it before every store reconciliation, so a response recheck it before every store reconciliation, so a response
produced by the previous runtime is rejected instead of mutating the current 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 runtime's live or global session state. Restore follows the same guard: a
observed runtime change: sessions the server already confirmed remain archived stale completion returns `false` without touching any store. A guarded batch
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed stops at the first observed runtime change: sessions the server already
on the captured runtime is returned in `failedIds` so existing partial-failure 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. feedback stays truthful.
Callers whose confirmation can span a runtime switch may pass an Callers whose confirmation can span a runtime switch may pass an
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. `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", () => { describe("fetchMessagesForSession startup race", () => {
test("does not reject before sync action refs are initialized", async () => { test("does not reject before sync action refs are initialized", async () => {
const { fetchMessagesForSession } = await import("./session-actions") const { fetchMessagesForSession } = await import("./session-actions")
+86
View File
@@ -1014,6 +1014,92 @@ export async function archiveSessions(
return { archivedIds, failedIds } 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> { export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId) const sessionDirectory = getSessionDirectory(sessionId)
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) 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(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] });
expect(updateSessionCalls).toEqual([]); 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', () => { describe('deleteSessions option forwarding', () => {
+9
View File
@@ -55,6 +55,8 @@ import {
deleteSessions as deleteSessionsAction, deleteSessions as deleteSessionsAction,
archiveSession as archiveSessionAction, archiveSession as archiveSessionAction,
archiveSessions as archiveSessionsAction, archiveSessions as archiveSessionsAction,
unarchiveSession as unarchiveSessionAction,
unarchiveSessions as unarchiveSessionsAction,
updateSessionTitle as updateSessionTitleAction, updateSessionTitle as updateSessionTitleAction,
shareSession as shareSessionAction, shareSession as shareSessionAction,
unshareSession as unshareSessionAction, unshareSession as unshareSessionAction,
@@ -67,6 +69,7 @@ import {
type ArchiveSessionsOptions, type ArchiveSessionsOptions,
type DeleteSessionOptions, type DeleteSessionOptions,
type DeleteSessionsOptions, type DeleteSessionsOptions,
type UnarchiveSessionsOptions,
} from "./session-actions" } from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store" import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore" import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
@@ -335,6 +338,8 @@ export type SessionUIState = {
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean> archiveSession: (id: string) => Promise<boolean>
archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }> 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> updateSessionTitle: (sessionId: string, title: string) => Promise<void>
shareSession: (sessionId: string) => Promise<Session | null> shareSession: (sessionId: string) => Promise<Session | null>
unshareSession: (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), archiveSessions: (ids, options) => archiveSessionsAction(ids, options),
unarchiveSession: (id) => unarchiveSessionAction(id),
unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options),
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// updateSessionTitle — calls SDK, SSE event updates child store // updateSessionTitle — calls SDK, SSE event updates child store
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------