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) => {
+8
View File
@@ -426,6 +426,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung',
'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.newFolderEllipsis': 'Neuer Ordner...',
'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.archive.success': 'Sitzung archiviert',
'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.failingCount': '{count} fehlgeschlagen',
'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.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen',
'sessions.archivePage.deleteSessionAria': '{title} löschen',
'sessions.archivePage.restoreSessionAria': '{title} wiederherstellen',
'header.sessionActions.openAria': 'Sitzungsaktionen öffnen',
'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren',
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
+8
View File
@@ -449,6 +449,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
'sessions.archivePage.deleteSessionAria': 'Delete {title}',
'sessions.archivePage.restoreSessionAria': 'Restore {title}',
'sessions.switcher.openAria': 'Open session switcher',
'sessions.switcher.empty': 'No recent sessions',
'sessions.switcher.draftTitle': 'New session',
@@ -470,6 +471,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Archived {count} sessions',
'sessions.sidebar.bulkActions.failedArchiveSingle': 'Failed to archive {count} session',
'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.newFolderEllipsis': 'New 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.archive.success': 'Session archived',
'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.failingCount': '{count} failing',
'sessions.sidebar.group.pr.pendingCount': '{count} pending',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Eliminar {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir selector de sesiones",
"sessions.switcher.empty": "No hay sesiones recientes",
"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.failedArchiveSingle": "No se pudo archivar {count} sesión",
"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.newFolderEllipsis": "Nueva 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.archive.success": "Sesión archivada",
"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.failingCount": "{count} con fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendientes",
+8
View File
@@ -285,6 +285,7 @@ export const dict = {
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet',
'sessions.archivePage.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
'sessions.archivePage.restoreSessionAria': 'Restaurer {title}',
'sessions.switcher.openAria': 'Sélecteur de session ouvert',
'sessions.switcher.empty': 'Aucune session récente',
'sessions.switcher.draftTitle': 'Nouvelle session',
@@ -306,6 +307,11 @@ export const dict = {
'sessions.sidebar.bulkActions.archivedPlural': 'Sessions {count} archivées',
'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.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.newFolderEllipsis': 'Nouveau 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.archive.success': 'Session archivée',
'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.failingCount': 'Échec de {count}',
'sessions.sidebar.group.pr.pendingCount': '{count} en attente',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
'sessions.archivePage.deleteSessionAria': '{title} を削除',
'sessions.archivePage.restoreSessionAria': '{title} を復元',
'sessions.switcher.openAria': 'セッションスイッチャーを開く',
'sessions.switcher.empty': '最近のセッションはありません',
'sessions.switcher.draftTitle': '新しいセッション',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました',
'sessions.sidebar.bulkActions.failedArchiveSingle': '{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.newFolderEllipsis': '新しいフォルダ...',
'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました',
'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました',
'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.failingCount': '{count}件失敗',
'sessions.sidebar.group.pr.pendingCount': '{count}件保留中',
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
'sessions.archivePage.deleteSessionAria': '{title} 삭제',
'sessions.archivePage.restoreSessionAria': '{title} 복원',
'sessions.switcher.openAria': '세션 전환기 열기',
'sessions.switcher.empty': '최근 세션 없음',
'sessions.switcher.draftTitle': '새 세션',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '세션 {count}개 보관됨',
'sessions.sidebar.bulkActions.failedArchiveSingle': '세션 {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.newFolderEllipsis': '새 폴더…',
'sessions.sidebar.folders.removeFromFolder': '폴더에서 제거',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '세션 삭제 실패',
'sessions.sidebar.session.archive.success': '세션 보관됨',
'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.failingCount': '실패 {count}개',
'sessions.sidebar.group.pr.pendingCount': '{count} 대기 중',
+8
View File
@@ -266,6 +266,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
'sessions.archivePage.restoreSessionAria': 'Przywróć {title}',
'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
'sessions.switcher.empty': 'Brak ostatnich sesji',
'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.failedArchiveSingle': '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.description': 'Zadania po stronie serwera, które tworzą nową sesję i wysyłają skonfigurowany prompt.',
'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.archive.success': 'Sesja zarchiwizowana',
'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.failingCount': '{count} niepowodzeń',
'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.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
"sessions.archivePage.deleteSessionAria": "Excluir {title}",
"sessions.archivePage.restoreSessionAria": "Restaurar {title}",
"sessions.switcher.openAria": "Abrir seletor de sessões",
"sessions.switcher.empty": "Nenhuma sessão recente",
"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.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.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.newFolderEllipsis": "Nova 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.archive.success": "Sessão archivada",
"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.failingCount": "{count} com fallos",
"sessions.sidebar.group.pr.pendingCount": "{count} pendentes",
+8
View File
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
"sessions.archivePage.deleteSessionAria": "Видалити {title}",
"sessions.archivePage.restoreSessionAria": "Відновити {title}",
"sessions.switcher.openAria": "Відкрити перемикач сесій",
"sessions.switcher.empty": "Немає недавніх сесій",
"sessions.switcher.draftTitle": "Нова сесія",
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {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.newFolderEllipsis": "Нова папка...",
"sessions.sidebar.folders.removeFromFolder": "Видалити з папки",
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.delete.error": "Не вдалося видалити сесію",
"sessions.sidebar.session.archive.success": "Сесію заархівовано",
"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.failingCount": "{count} з помилкою",
"sessions.sidebar.group.pr.pendingCount": "{count} очікує",
@@ -450,6 +450,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话',
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
'sessions.archivePage.deleteSessionAria': '删除 {title}',
'sessions.archivePage.restoreSessionAria': '还原 {title}',
'sessions.switcher.openAria': '打开会话切换器',
'sessions.switcher.empty': '没有最近会话',
'sessions.switcher.draftTitle': '新会话',
@@ -471,6 +472,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已归档 {count} 个会话',
'sessions.sidebar.bulkActions.failedArchiveSingle': '归档 {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.newFolderEllipsis': '新建文件夹...',
'sessions.sidebar.folders.removeFromFolder': '从文件夹中移除',
@@ -574,6 +580,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '删除会话失败',
'sessions.sidebar.session.archive.success': '会话已归档',
'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.failingCount': '{count} 项失败',
'sessions.sidebar.group.pr.pendingCount': '{count} 项等待中',
@@ -463,6 +463,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段',
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
'sessions.archivePage.deleteSessionAria': '刪除 {title}',
'sessions.archivePage.restoreSessionAria': '還原 {title}',
'sessions.switcher.openAria': '開啟會話切換器',
'sessions.switcher.empty': '沒有最近會話',
'sessions.switcher.draftTitle': '新會話',
@@ -484,6 +485,11 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.bulkActions.archivedPlural': '已封存 {count} 個會話',
'sessions.sidebar.bulkActions.failedArchiveSingle': '封存 {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.newFolderEllipsis': '新增資料夾...',
'sessions.sidebar.folders.removeFromFolder': '從資料夾中移除',
@@ -587,6 +593,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.delete.error': '刪除會話失敗',
'sessions.sidebar.session.archive.success': '會話已封存',
'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.failingCount': '{count} 項失敗',
'sessions.sidebar.group.pr.pendingCount': '{count} 項等待中',
+2 -2
View File
@@ -59,8 +59,8 @@ User-visible session ordering is also not owned by the global cache array order.
Global refresh rules:
- 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.
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
- 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 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.
- 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.
+35 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages } from './globalSessions'
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('listGlobalSessionPages', () => {
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'])
})
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 () => {
const calls: Array<Record<string, unknown>> = []
const apiClient = {
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
})
})
describe('splitGlobalSessionsByArchived', () => {
test('classifies restored (falsy archived) records as active', () => {
const { active, archived } = splitGlobalSessionsByArchived([
{ id: 'ses_active', time: { created: 1, updated: 20 } },
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
] as unknown as Parameters<typeof splitGlobalSessionsByArchived>[0])
expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored'])
expect(archived.map((session) => session.id)).toEqual(['ses_archived'])
})
})
+31 -4
View File
@@ -84,11 +84,38 @@ const unwrapSessionList = (
*/
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
/**
* 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(
apiClient: OpencodeClient,
options: {
directory?: string;
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;
pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void;
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>();
let cursor: number | undefined;
const narrowToArchived = options.narrowToArchived !== false;
let operation: string;
if (!options.directory) {
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
} else if (options.roots === true) {
operation = "bootstrap.sessions.roots";
} else if (options.archived) {
operation = "bootstrap.sessions.archived";
operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
} else {
operation = "bootstrap.sessions.all";
}
while (true) {
let attempts = 0;
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id);
appended += 1;
if (options.archived && !isArchivedSession(session)) continue;
if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
all.push(session);
accepted.push(session);
}
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
return { promise, resolve, reject }
}
let activeRequest: Deferred<Session[]>
let archivedRequest: Deferred<Session[]>
let listRequest: 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 = {
experimental: {
session: {
list: async (options: { archived?: boolean }) => ({
data: await (options.archived ? archivedRequest.promise : activeRequest.promise),
list: async () => ({
data: await listRequest.promise,
response: { headers: new Headers() },
}),
},
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
const session = (id: string, title = id, archived?: number): Session => ({
id,
title,
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) },
time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
} as Session)
describe("global session mutation reconciliation", () => {
beforeEach(() => {
activeRequest = deferred<Session[]>()
archivedRequest = deferred<Session[]>()
listRequest = deferred<Session[]>()
opencodeClient.getSdkClient = () => sdk
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
})
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("created"))
activeRequest.resolve([])
archivedRequest.resolve([])
listRequest.resolve([])
await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().removeSessions([stale.id])
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
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 loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(created)
activeRequest.reject(new Error("unavailable"))
archivedRequest.resolve([])
listRequest.reject(new Error("unavailable"))
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
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 () => {
const source = { ...session("moved"), directory: "/source" } as Session
const destination = { ...source, directory: "/destination" } as Session
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession(destination)
activeRequest.resolve([source])
archivedRequest.resolve([])
listRequest.resolve([source])
await refreshing
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
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 type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
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 { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
const fetchDirectoryPages = async (
sdk: OpencodeClient,
directories: Set<string>,
archived: boolean,
): Promise<DirectoryPageResult> => {
const currentDirectory = normalizePath(opencodeClient.getDirectory());
const orderedDirectories = [...directories].sort((left, right) => {
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
status: 'fulfilled' as const,
value: {
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(() => (
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 () => {
try {
const sdk = opencodeClient.getSdkClient();
const [activeResult, archivedResult] = await Promise.allSettled([
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }),
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
]);
if (activeResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
}
if (archivedResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
// One inclusive fetch, split client-side. The server's
// `time_archived IS NULL` active filter would exclude restored
// sessions (`time.archived` falsy-but-present), so an
// `archived: false` request cannot produce a truthful active list.
const allSessions = await listGlobalSessionPages(sdk, {
archived: true,
narrowToArchived: false,
pageSize: PAGE_SIZE,
});
if (generation !== loadGeneration) {
// Runtime switched mid-load: this snapshot belongs to the previous
// instance — drop it.
return { activeSessions: [], archivedSessions: [] };
}
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
? 'ready'
: 'error';
const { active, archived } = splitGlobalSessionsByArchived(allSessions);
set((state) => {
const fetchedActive = activeResult.status === 'fulfilled'
? activeResult.value
: 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 reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
});
const committed = get();
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const generation = loadGeneration;
const baselineRevision = get().mutationRevision;
const sdk = opencodeClient.getSdkClient();
const [active, archived] = await Promise.all([
fetchDirectoryPages(sdk, directorySet, false),
fetchDirectoryPages(sdk, directorySet, true),
]);
const fetched = await fetchDirectoryPages(sdk, directorySet);
if (generation !== loadGeneration) {
const state = get();
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
}
if (active.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
}
if (archived.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
if (fetched.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
}
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
set((state) => {
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
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)) {
nextArchivedSessions = state.archivedSessions;
}
+26 -4
View File
@@ -240,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`:
- `updateSessionTitle()` -> `upsertSession(result.data)`
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `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
- `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
recheck it before every store reconciliation, so a response
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
observed runtime change: sessions the server already confirmed remain archived
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed
on the captured runtime is returned in `failedIds` so existing partial-failure
runtime's live or global session state. Restore follows the same guard: a
stale completion returns `false` without touching any store. A guarded batch
stops at the first observed runtime change: sessions the server already
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.
Callers whose confirmation can span a runtime switch may pass an
`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", () => {
test("does not reject before sync action refs are initialized", async () => {
const { fetchMessagesForSession } = await import("./session-actions")
+86
View File
@@ -1014,6 +1014,92 @@ export async function archiveSessions(
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> {
const sessionDirectory = getSessionDirectory(sessionId)
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(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', () => {
+9
View File
@@ -55,6 +55,8 @@ import {
deleteSessions as deleteSessionsAction,
archiveSession as archiveSessionAction,
archiveSessions as archiveSessionsAction,
unarchiveSession as unarchiveSessionAction,
unarchiveSessions as unarchiveSessionsAction,
updateSessionTitle as updateSessionTitleAction,
shareSession as shareSessionAction,
unshareSession as unshareSessionAction,
@@ -67,6 +69,7 @@ import {
type ArchiveSessionsOptions,
type DeleteSessionOptions,
type DeleteSessionsOptions,
type UnarchiveSessionsOptions,
} from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
@@ -335,6 +338,8 @@ export type SessionUIState = {
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean>
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>
shareSession: (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),
unarchiveSession: (id) => unarchiveSessionAction(id),
unarchiveSessions: (ids, options) => unarchiveSessionsAction(ids, options),
// ---------------------------------------------------------------------------
// updateSessionTitle — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------