feat(vscode): add archive all sessions action (#1262)

* feat(vscode): add archive all sessions action

* Update packages/ui/src/components/layout/VSCodeLayout.tsx

Signed-off-by: Jake <101855602+jjdubski@users.noreply.github.com>

* fix: Fail-open default archives all sessions when workspace can't be resolved

* fix: Already-archived descendants are included in the archive call

* fix: add i18n for the newly added toasts

* Restore icon name from 'add-line' to 'add'

Signed-off-by: Jake <101855602+jjdubski@users.noreply.github.com>

* update: fix merge conflict and update code structure into components

---------

Signed-off-by: Jake <101855602+jjdubski@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jake
2026-06-08 15:11:05 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent bba4b02dca
commit 5589ca991a
12 changed files with 208 additions and 5 deletions
@@ -5,11 +5,13 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { SessionsTabTitle } from '@/components/session/SessionsTabTitle';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import {
@@ -22,6 +24,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
@@ -32,7 +35,7 @@ import { useUpdateStore } from '@/stores/useUpdateStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { UsageWindow } from '@/types';
import type { Session, UsageWindow } from '@/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
@@ -56,6 +59,15 @@ const SESSIONS_SIDEBAR_WIDTH = 280;
const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7);
const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const replaced = trimmed.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
@@ -135,6 +147,18 @@ export const VSCodeLayout: React.FC = () => {
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const globalArchivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const activeWorkspacePath = React.useMemo(() => {
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: projects[0] ?? null;
return normalizePath(activeProject?.path ?? null);
}, [activeProjectId, projects]);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const activeSessionTitleValue = useDirectorySync(
React.useCallback((state) => {
@@ -249,6 +273,82 @@ export const VSCodeLayout: React.FC = () => {
setCurrentView('sessions');
}, []);
const isSessionInActiveWorkspace = React.useCallback((session: Session): boolean => {
if (!activeWorkspacePath) {
return false;
}
const sessionDirectory = resolveGlobalSessionDirectory(session);
if (sessionDirectory) {
return sessionDirectory.toLowerCase() === activeWorkspacePath.toLowerCase();
}
return false;
}, [activeWorkspacePath]);
const traversalSessions = React.useMemo(() => {
const byId = new Map<string, Session>();
for (const session of sessions) byId.set(session.id, session);
for (const session of globalActiveSessions) byId.set(session.id, session);
for (const session of globalArchivedSessions) byId.set(session.id, session);
return Array.from(byId.values());
}, [globalActiveSessions, globalArchivedSessions, sessions]);
/** Collect root session IDs and all descendants (subagent sessions). */
const collectSessionIdsWithDescendants = React.useCallback(
(allSessions: Session[], rootSessions: Session[]): string[] => {
const byId = new Map<string, Session>();
for (const session of allSessions) byId.set(session.id, session);
const childrenMap = new Map<string, string[]>();
for (const session of allSessions) {
const parentID = session.parentID;
if (parentID) {
const list = childrenMap.get(parentID) ?? [];
list.push(session.id);
childrenMap.set(parentID, list);
}
}
const ids = new Set<string>();
const addDescendants = (sessionId: string, visited: Set<string>) => {
if (visited.has(sessionId)) return; // cycle guard
visited.add(sessionId);
const children = childrenMap.get(sessionId) ?? [];
for (const childId of children) {
const child = byId.get(childId);
if (child?.time?.archived) continue; // skip already-archived children
ids.add(childId);
addDescendants(childId, visited);
}
};
for (const session of rootSessions) {
ids.add(session.id);
addDescendants(session.id, new Set());
}
return Array.from(ids);
},
[],
);
const handleArchiveAll = React.useCallback(async () => {
const store = useSessionUIStore.getState();
const rootSessions = traversalSessions.filter((session) => !session.time?.archived && isSessionInActiveWorkspace(session));
const allIds = collectSessionIdsWithDescendants(traversalSessions, rootSessions);
if (allIds.length === 0) return;
const { archivedIds, failedIds } = await store.archiveSessions(allIds);
if (archivedIds.length > 0) {
toast.success(t('vscodeLayout.actions.archiveAllSuccess', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(t('vscodeLayout.actions.archiveAllError', { count: failedIds.length }));
}
}, [collectSessionIdsWithDescendants, isSessionInActiveWorkspace, traversalSessions, t]);
// Listen for connection status changes
React.useEffect(() => {
// Catch up with the latest status even if the extension posted the connection message
@@ -519,6 +619,7 @@ export const VSCodeLayout: React.FC = () => {
<div className="flex flex-col h-full">
<VSCodeHeader
title={t('vscodeLayout.title.sessions')}
onArchiveAll={handleArchiveAll}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
@@ -559,6 +660,7 @@ interface VSCodeHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onArchiveAll?: () => void;
onNewSession?: () => void;
onSettings?: () => void;
onAgentManager?: () => void;
@@ -568,7 +670,8 @@ interface VSCodeHeaderProps {
enableSessionSwitcher?: boolean;
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits, enableSessionSwitcher }) => {
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onArchiveAll, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits, enableSessionSwitcher }) => {
const { t } = useI18n();
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const providers = useConfigStore((state) => state.providers);
@@ -744,7 +847,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
</button>
</SessionSwitcherDropdown>
) : (
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
<SessionsTabTitle title={title} onArchiveAll={onArchiveAll} />
)}
<div className="min-w-0 flex-1" />
{onNewSession && (
@@ -0,0 +1,44 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface ArchiveAllDropdownProps {
onArchiveAll?: () => void;
}
const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll }) => {
const { t } = useI18n();
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('vscodeLayout.actions.archiveAllAria')}
>
<Icon name="archive" className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>{t('vscodeLayout.actions.archiveAllAria')}</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onSelect={onArchiveAll}>
{t('vscodeLayout.actions.archiveAllConfirm')}
</DropdownMenuItem>
<DropdownMenuItem>{t('vscodeLayout.actions.cancel')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export { ArchiveAllDropdown };
@@ -0,0 +1,16 @@
import React from 'react';
import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown';
interface SessionsTabTitleProps {
title: string;
onArchiveAll?: () => void;
}
const SessionsTabTitle: React.FC<SessionsTabTitleProps> = ({ title, onArchiveAll }) => (
<>
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
{onArchiveAll && <ArchiveAllDropdown onArchiveAll={onArchiveAll} />}
</>
);
export { SessionsTabTitle };
@@ -821,7 +821,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
<Icon name={archivedBucket ? "delete-bin" : "archive"} className="mr-1 h-4 w-4" />
{archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem>
</DropdownMenuContent>