Merge pull request #2616 from makeittech/feat/restore-archived-sessions-2346
feat: add restore/unarchive for archived sessions
This commit is contained in:
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
const 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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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} 대기 중',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} 項等待中',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user