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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
bba4b02dca
commit
5589ca991a
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user