Merge pull request #2616 from makeittech/feat/restore-archived-sessions-2346
feat: add restore/unarchive for archived sessions
This commit is contained in:
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
|
||||
const {
|
||||
copiedSessionId,
|
||||
@@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
mobileVariant,
|
||||
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
unarchiveSession,
|
||||
childrenMap,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
|
||||
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
|
||||
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
|
||||
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
|
||||
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
|
||||
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
@@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
openContextPanelTab={openContextPanelTab}
|
||||
handleDeleteSession={stableHandleDeleteSession}
|
||||
handleRestoreSession={stableHandleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
renderSessionNode={renderSessionNode}
|
||||
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
} = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulkCanRemoveFromFolder}
|
||||
onRestore={handleBulkRestore}
|
||||
onDelete={handleBulkDelete}
|
||||
onDone={handleExitSelectionMode}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
onRemoveFromFolder: () => void;
|
||||
canRemoveFromFolder: boolean;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
onCreateFolderAndMove,
|
||||
onRemoveFromFolder,
|
||||
canRemoveFromFolder,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onDone,
|
||||
}) => {
|
||||
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{archivedBucket ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.restore')}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
|
||||
@@ -92,6 +92,7 @@ type Props = {
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (
|
||||
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{t('sessions.sidebar.bulkActions.archive')}
|
||||
</Item>
|
||||
) : null}
|
||||
{archivedBucket ? (
|
||||
<Item className="[&>svg]:mr-1" onClick={() => handleRestoreSession(session)}>
|
||||
<Icon name="inbox-unarchive" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.restore')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket, hardDelete: true })}>
|
||||
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.delete')}
|
||||
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type Args = {
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSession: (id: string) => Promise<boolean>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
|
||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||
}, [args, executeDeleteSession]);
|
||||
|
||||
const handleRestoreSession = React.useCallback(
|
||||
async (session: Session) => {
|
||||
const success = await args.unarchiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
},
|
||||
[args, t],
|
||||
);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
handleSessionSelect,
|
||||
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type Args = {
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
|
||||
sessionCount: number;
|
||||
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
} = args;
|
||||
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
|
||||
|
||||
const handleBulkRestore = React.useCallback(async () => {
|
||||
if (!hasSelection || !bulkScopeIsArchived) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const { restoredIds, failedIds } = await unarchiveSessions(ids);
|
||||
if (restoredIds.length > 0) {
|
||||
toast.success(restoredIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
|
||||
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
});
|
||||
}, [t, unarchiveSession]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderDirectoryItem = (
|
||||
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-14 focus-within:pr-14"
|
||||
onClick={() => openSession(session)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
|
||||
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
|
||||
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
restoreSession(session);
|
||||
}}
|
||||
className="absolute right-7 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-foreground group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.restoreSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
|
||||
Reference in New Issue
Block a user