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

feat: add restore/unarchive for archived sessions
This commit is contained in:
Serhii Dziupin
2026-08-04 13:41:00 +03:00
committed by GitHub
28 changed files with 556 additions and 69 deletions
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const 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) => {