feat: add restore/unarchive for archived sessions
Archived sessions had no way back to the active list: the only available action was "Delete permanently". Add restore per session (sidebar context menu, Archive page row) and in bulk (sidebar selection bar). The OpenCode server cannot clear time.archived over HTTP — session.update only applies the field for a finite number, so an omitted key is a no-op and null is silently ignored (verified against opencode 1.18.12). Restore therefore writes time.archived = 0: every client-side reader classifies archive state by truthiness, 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 the global session cache no longer issues an archived:false request for its active list. Full and per-directory loads now fetch once with the inclusive flag and split client-side via splitGlobalSessionsByArchived, which also halves per-directory refresh requests. Directory bootstrap keeps the server filter because live child stores must not hold archived sessions; a restored session re-enters its live store through the authoritative session.updated event. unarchiveSession/unarchiveSessions follow the archiveSession contract: wait for server confirmation before reconciling stores, runtime-guard every reconciliation, preserve partial batch results, and fail loudly when the server keeps the session archived instead of toasting a successful no-op. Closes #2346
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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user