From 53c103a2222019596575b052cd1f4c875e781323 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 20 Dec 2025 02:06:00 +0200 Subject: [PATCH] feat: add session auto-cleanup settings and functionality --- .../src-tauri/src/commands/settings.rs | 15 ++ packages/ui/src/App.tsx | 2 + .../settings/SessionRetentionSettings.tsx | 136 +++++++++++++ .../sections/settings/SettingsPage.tsx | 4 + .../ui/src/hooks/useSessionAutoCleanup.ts | 179 ++++++++++++++++++ packages/ui/src/lib/api/types.ts | 2 + packages/ui/src/lib/appearanceAutoSave.ts | 12 ++ packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/persistence.ts | 15 ++ packages/ui/src/stores/sessionStore.ts | 13 +- packages/ui/src/stores/types/sessionTypes.ts | 2 +- packages/ui/src/stores/useUIStore.ts | 25 +++ packages/vscode/webview/stores/chatStore.ts | 162 ++++++++++++++++ packages/web/server/index.js | 7 + 14 files changed, 571 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx create mode 100644 packages/ui/src/hooks/useSessionAutoCleanup.ts diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index d8167d1e..0bfb2c4c 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -136,6 +136,21 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") { result_obj.insert("showReasoningTraces".to_string(), json!(b)); } + if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") { + result_obj.insert("autoDeleteEnabled".to_string(), json!(b)); + } + + // Number fields + if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") { + let parsed = n + .as_u64() + .or_else(|| n.as_i64().and_then(|value| if value >= 0 { Some(value as u64) } else { None })) + .or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64)); + if let Some(value) = parsed { + let clamped = value.max(1).min(365); + result_obj.insert("autoDeleteAfterDays".to_string(), json!(clamped)); + } + } // Array fields if let Some(arr) = obj.get("approvedDirectories") { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 15832a84..4e77adcb 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,6 +10,7 @@ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useMenuActions } from '@/hooks/useMenuActions'; import { useMessageSync } from '@/hooks/useMessageSync'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; +import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; @@ -152,6 +153,7 @@ function App({ apis }: AppProps) { useMessageSync(); useSessionStatusBootstrap(); + useSessionAutoCleanup(); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx new file mode 100644 index 00000000..884f8a18 --- /dev/null +++ b/packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { toast } from 'sonner'; +import { RiInformationLine } from '@remixicon/react'; +import { NumberInput } from '@/components/ui/number-input'; +import { ButtonSmall } from '@/components/ui/button-small'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDeviceInfo } from '@/lib/device'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; + +const MIN_DAYS = 1; +const MAX_DAYS = 365; + +export const SessionRetentionSettings: React.FC = () => { + const { isMobile } = useDeviceInfo(); + const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); + const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled); + const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays); + + const [mobileDraftDays, setMobileDraftDays] = React.useState(String(autoDeleteAfterDays)); + + React.useEffect(() => { + setMobileDraftDays(String(autoDeleteAfterDays)); + }, [autoDeleteAfterDays]); + + const { candidates, isRunning, runCleanup, keepRecentCount } = useSessionAutoCleanup({ autoRun: false }); + const pendingCount = candidates.length; + + const handleRunCleanup = React.useCallback(async () => { + const result = await runCleanup({ force: true }); + if (result.deletedIds.length === 0 && result.failedIds.length === 0) { + toast.message('No sessions eligible for deletion'); + return; + } + if (result.deletedIds.length > 0) { + toast.success(`Deleted ${result.deletedIds.length} session${result.deletedIds.length === 1 ? '' : 's'}`); + } + if (result.failedIds.length > 0) { + toast.error(`Failed to delete ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`); + } + }, [runCleanup]); + + return ( +
+
+
+

Session retention

+ + + + + + Automatically delete inactive sessions based on their last activity.
+ You can also run a one-time cleanup without enabling auto-cleanup.
+ Keeps the most recent 5 sessions, and never deletes shared sessions. +
+
+
+
+ + + +
+
+ {isMobile ? ( + { + const nextValue = event.target.value; + setMobileDraftDays(nextValue); + if (nextValue.trim() === '') { + return; + } + const parsed = Number(nextValue); + if (!Number.isFinite(parsed)) { + return; + } + const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed))); + setAutoDeleteAfterDays(clamped); + }} + onBlur={() => { + if (mobileDraftDays.trim() === '') { + setMobileDraftDays(String(autoDeleteAfterDays)); + return; + } + const parsed = Number(mobileDraftDays); + if (!Number.isFinite(parsed)) { + setMobileDraftDays(String(autoDeleteAfterDays)); + return; + } + const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed))); + setAutoDeleteAfterDays(clamped); + setMobileDraftDays(String(clamped)); + }} + aria-label="Retention period in days" + className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50" + /> + ) : ( + + )} + days since last activity +
+ + {isRunning ? 'Cleaning up...' : 'Run cleanup now'} + +
+ +
+ Eligible for deletion right now: {pendingCount} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/settings/SettingsPage.tsx b/packages/ui/src/components/sections/settings/SettingsPage.tsx index 1f46b963..cced594c 100644 --- a/packages/ui/src/components/sections/settings/SettingsPage.tsx +++ b/packages/ui/src/components/sections/settings/SettingsPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { AppearanceSettings } from './AppearanceSettings'; import { AboutSettings } from './AboutSettings'; +import { SessionRetentionSettings } from './SessionRetentionSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isWebRuntime } from '@/lib/desktop'; @@ -15,6 +16,9 @@ export const SettingsPage: React.FC = () => { className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6" > +
+ +
{showAbout && (
diff --git a/packages/ui/src/hooks/useSessionAutoCleanup.ts b/packages/ui/src/hooks/useSessionAutoCleanup.ts new file mode 100644 index 00000000..41e131c9 --- /dev/null +++ b/packages/ui/src/hooks/useSessionAutoCleanup.ts @@ -0,0 +1,179 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const AUTO_DELETE_KEEP_RECENT = 5; +const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +const getSessionLastActivity = (session: Session): number => { + return session.time?.updated ?? session.time?.created ?? 0; +}; + +type BuildAutoDeleteCandidatesOptions = { + sessions: Session[]; + currentSessionId: string | null; + cutoffDays: number; + keepRecent?: number; + now?: number; +}; + +export const buildAutoDeleteCandidates = ({ + sessions, + currentSessionId, + cutoffDays, + keepRecent = AUTO_DELETE_KEEP_RECENT, + now = Date.now(), +}: BuildAutoDeleteCandidatesOptions): string[] => { + if (!Array.isArray(sessions) || cutoffDays <= 0) { + return []; + } + + const cutoffTime = now - cutoffDays * DAY_MS; + const sorted = [...sessions].sort( + (a, b) => getSessionLastActivity(b) - getSessionLastActivity(a) + ); + const protectedIds = new Set(sorted.slice(0, keepRecent).map((session) => session.id)); + + return sorted + .filter((session) => { + if (!session?.id) return false; + if (protectedIds.has(session.id)) return false; + if (session.id === currentSessionId) return false; + if (session.share) return false; + const lastActivity = getSessionLastActivity(session); + if (!lastActivity) return false; + return lastActivity < cutoffTime; + }) + .map((session) => session.id); +}; + +type CleanupResult = { + deletedIds: string[]; + failedIds: string[]; + skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running'; +}; + +type CleanupOptions = { + autoRun?: boolean; +}; + +export const useSessionAutoCleanup = (options?: CleanupOptions) => { + const autoRun = options?.autoRun !== false; + + const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const isLoading = useSessionStore((state) => state.isLoading); + const deleteSessions = useSessionStore((state) => state.deleteSessions); + + const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); + const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt); + const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt); + + const [isRunning, setIsRunning] = React.useState(false); + const runningRef = React.useRef(false); + + const candidates = React.useMemo(() => { + if (autoDeleteAfterDays <= 0) { + return []; + } + return buildAutoDeleteCandidates({ + sessions, + currentSessionId, + cutoffDays: autoDeleteAfterDays, + }); + }, [autoDeleteAfterDays, currentSessionId, sessions]); + + const runCleanup = React.useCallback( + async ({ force = false }: { force?: boolean } = {}): Promise => { + if (runningRef.current) { + return { deletedIds: [], failedIds: [], skippedReason: 'running' }; + } + + if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { + if (!force) { + return { deletedIds: [], failedIds: [], skippedReason: 'disabled' }; + } + } + + if (isLoading) { + return { deletedIds: [], failedIds: [], skippedReason: 'loading' }; + } + + const now = Date.now(); + if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) { + return { deletedIds: [], failedIds: [], skippedReason: 'cooldown' }; + } + + if (sessions.length === 0) { + return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + } + + const candidateIds = buildAutoDeleteCandidates({ + sessions, + currentSessionId, + cutoffDays: autoDeleteAfterDays, + now, + }); + + if (candidateIds.length === 0) { + setAutoDeleteLastRunAt(now); + return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + } + + runningRef.current = true; + setIsRunning(true); + try { + const result = await deleteSessions(candidateIds, { silent: true }); + return result; + } finally { + runningRef.current = false; + setIsRunning(false); + setAutoDeleteLastRunAt(Date.now()); + } + }, + [ + autoDeleteAfterDays, + autoDeleteEnabled, + autoDeleteLastRunAt, + currentSessionId, + deleteSessions, + isLoading, + sessions, + setAutoDeleteLastRunAt, + ] + ); + + React.useEffect(() => { + if (!autoRun) { + return; + } + if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { + return; + } + if (isLoading || sessions.length === 0) { + return; + } + const now = Date.now(); + if (autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) { + return; + } + void runCleanup(); + }, [ + autoDeleteAfterDays, + autoDeleteEnabled, + autoDeleteLastRunAt, + autoRun, + isLoading, + runCleanup, + ]); + + return { + candidates, + isRunning, + runCleanup, + keepRecentCount: AUTO_DELETE_KEEP_RECENT, + }; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index ca938c97..0f520976 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -330,6 +330,8 @@ export interface SettingsPayload { securityScopedBookmarks?: string[]; pinnedDirectories?: string[]; showReasoningTraces?: boolean; + autoDeleteEnabled?: boolean; + autoDeleteAfterDays?: number; [key: string]: unknown; } diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 23ce4a2e..6ac79ad7 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -4,6 +4,8 @@ import type { DesktopSettings } from '@/lib/desktop'; type AppearanceSlice = { showReasoningTraces: boolean; + autoDeleteEnabled: boolean; + autoDeleteAfterDays: number; }; let initialized = false; @@ -17,6 +19,8 @@ export const startAppearanceAutoSave = (): void => { let previous: AppearanceSlice = { showReasoningTraces: useUIStore.getState().showReasoningTraces, + autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, + autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, }; let pending: Partial | null = null; @@ -42,6 +46,8 @@ export const startAppearanceAutoSave = (): void => { useUIStore.subscribe((state) => { const current: AppearanceSlice = { showReasoningTraces: state.showReasoningTraces, + autoDeleteEnabled: state.autoDeleteEnabled, + autoDeleteAfterDays: state.autoDeleteAfterDays, }; const diff: Partial = {}; @@ -49,6 +55,12 @@ export const startAppearanceAutoSave = (): void => { if (current.showReasoningTraces !== previous.showReasoningTraces) { diff.showReasoningTraces = current.showReasoningTraces; } + if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) { + diff.autoDeleteEnabled = current.autoDeleteEnabled; + } + if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) { + diff.autoDeleteAfterDays = current.autoDeleteAfterDays; + } previous = current; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 7616f254..b4d8751b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -39,6 +39,8 @@ export type DesktopSettings = { securityScopedBookmarks?: string[]; pinnedDirectories?: string[]; showReasoningTraces?: boolean; + autoDeleteEnabled?: boolean; + autoDeleteAfterDays?: number; }; export type DesktopSettingsApi = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 7a5483f9..cbb83e9a 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -59,6 +59,15 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { store.setShowReasoningTraces(settings.showReasoningTraces); } + if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) { + store.setAutoDeleteEnabled(settings.autoDeleteEnabled); + } + if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) { + const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays)); + if (normalized !== store.autoDeleteAfterDays) { + store.setAutoDeleteAfterDays(normalized); + } + } }; const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { @@ -110,6 +119,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.autoDeleteEnabled === 'boolean') { + result.autoDeleteEnabled = candidate.autoDeleteEnabled; + } + if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { + result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; + } return result; }; diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index 4bae2cef..a849b060 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -653,13 +653,19 @@ export const useSessionStore = create()( } }, - deleteSessions: async (ids: string[], options) => { + deleteSessions: async ( + ids: string[], + options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean } + ) => { const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); if (uniqueIds.length === 0) { return { deletedIds: [], failedIds: [] }; } - set({ isLoading: true, error: null }); + const silent = options?.silent === true; + if (!silent) { + set({ isLoading: true, error: null }); + } const deletedIds: string[] = []; const failedIds: string[] = []; const archivedIds = new Set(); @@ -743,10 +749,9 @@ export const useSessionStore = create()( return { sessions: filteredSessions, currentSessionId: nextCurrentId, - isLoading: false, + ...(silent ? {} : { isLoading: false, error: errorMessage }), worktreeMetadata: nextMetadata, availableWorktrees: nextAvailableWorktrees, - error: errorMessage, }; }); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 3e677f5e..37c3f9e5 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -104,7 +104,7 @@ export interface SessionStore { createSessionFromAssistantMessage: (sourceMessageId: string) => Promise; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise; - deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index efdd4cf9..877d90ea 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -33,6 +33,9 @@ interface UIStore { eventStreamStatus: EventStreamStatus; eventStreamHint: string | null; showReasoningTraces: boolean; + autoDeleteEnabled: boolean; + autoDeleteAfterDays: number; + autoDeleteLastRunAt: number | null; toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; fontSize: number; @@ -66,6 +69,9 @@ interface UIStore { setSidebarSection: (section: SidebarSection) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setShowReasoningTraces: (value: boolean) => void; + setAutoDeleteEnabled: (value: boolean) => void; + setAutoDeleteAfterDays: (days: number) => void; + setAutoDeleteLastRunAt: (timestamp: number | null) => void; setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void; setFontSize: (size: number) => void; setPadding: (size: number) => void; @@ -102,6 +108,9 @@ export const useUIStore = create()( eventStreamStatus: 'idle', eventStreamHint: null, showReasoningTraces: false, + autoDeleteEnabled: false, + autoDeleteAfterDays: 30, + autoDeleteLastRunAt: null, toolCallExpansion: 'collapsed', fontSize: 100, padding: 100, @@ -222,6 +231,19 @@ export const useUIStore = create()( set({ showReasoningTraces: value }); }, + setAutoDeleteEnabled: (value) => { + set({ autoDeleteEnabled: value }); + }, + + setAutoDeleteAfterDays: (days) => { + const clampedDays = Math.max(1, Math.min(365, days)); + set({ autoDeleteAfterDays: clampedDays }); + }, + + setAutoDeleteLastRunAt: (timestamp) => { + set({ autoDeleteLastRunAt: timestamp }); + }, + setToolCallExpansion: (value) => { set({ toolCallExpansion: value }); }, @@ -399,6 +421,9 @@ export const useUIStore = create()( isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, isSettingsDialogOpen: state.isSettingsDialogOpen, showReasoningTraces: state.showReasoningTraces, + autoDeleteEnabled: state.autoDeleteEnabled, + autoDeleteAfterDays: state.autoDeleteAfterDays, + autoDeleteLastRunAt: state.autoDeleteLastRunAt, toolCallExpansion: state.toolCallExpansion, fontSize: state.fontSize, padding: state.padding, diff --git a/packages/vscode/webview/stores/chatStore.ts b/packages/vscode/webview/stores/chatStore.ts index eda72532..9a00d0cd 100644 --- a/packages/vscode/webview/stores/chatStore.ts +++ b/packages/vscode/webview/stores/chatStore.ts @@ -5,6 +5,65 @@ import type { Session, Message, Part } from '@opencode-ai/sdk'; const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339'; const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || ''; +const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt'; +const AUTO_DELETE_DEFAULT_DAYS = 30; +const AUTO_DELETE_KEEP_RECENT = 5; +const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +let autoDeleteRunning = false; + +const getLastActivity = (session: Session): number => { + return session.time?.updated ?? session.time?.created ?? 0; +}; + +const readAutoDeleteLastRunAt = (): number | null => { + if (typeof window === 'undefined') return null; + try { + const value = window.localStorage.getItem(AUTO_DELETE_STORAGE_KEY); + if (!value) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const writeAutoDeleteLastRunAt = (timestamp: number) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(AUTO_DELETE_STORAGE_KEY, String(timestamp)); + } catch { + // ignore storage errors + } +}; + +const buildAutoDeleteCandidates = ( + sessions: Session[], + currentSessionId: string | null, + cutoffDays: number, + now = Date.now() +): string[] => { + if (!Array.isArray(sessions) || cutoffDays <= 0) { + return []; + } + + const cutoffTime = now - cutoffDays * 24 * 60 * 60 * 1000; + const sorted = [...sessions].sort((a, b) => getLastActivity(b) - getLastActivity(a)); + const protectedIds = new Set(sorted.slice(0, AUTO_DELETE_KEEP_RECENT).map((session) => session.id)); + + return sorted + .filter((session) => { + if (!session?.id) return false; + if (protectedIds.has(session.id)) return false; + if (session.id === currentSessionId) return false; + if (session.share) return false; + const lastActivity = getLastActivity(session); + if (!lastActivity) return false; + return lastActivity < cutoffTime; + }) + .map((session) => session.id); +}; + interface MessageRecord { info: Message; parts: Part[]; @@ -19,6 +78,9 @@ interface ChatState { sessions: Session[]; currentSessionId: string | null; isLoadingSessions: boolean; + autoDeleteEnabled: boolean; + autoDeleteAfterDays: number; + autoDeleteLastRunAt: number | null; // Messages messages: Map; @@ -28,6 +90,8 @@ interface ChatState { // Actions initialize: () => Promise; + loadAutoDeleteSettings: () => Promise; + runAutoCleanup: (sessionsOverride?: Session[]) => Promise; loadSessions: () => Promise; createSession: () => Promise; selectSession: (sessionId: string) => Promise; @@ -42,6 +106,9 @@ export const useChatStore = create((set, get) => ({ sessions: [], currentSessionId: null, isLoadingSessions: false, + autoDeleteEnabled: false, + autoDeleteAfterDays: AUTO_DELETE_DEFAULT_DAYS, + autoDeleteLastRunAt: readAutoDeleteLastRunAt(), messages: new Map(), isLoadingMessages: false, isSending: false, @@ -55,6 +122,7 @@ export const useChatStore = create((set, get) => ({ try { await client.session.list({ query: { directory: getWorkspaceFolder() } }); set({ client, isConnected: true }); + await get().loadAutoDeleteSettings(); await get().loadSessions(); } catch (error) { console.error('Failed to connect to OpenCode API:', error); @@ -62,6 +130,99 @@ export const useChatStore = create((set, get) => ({ } }, + loadAutoDeleteSettings: async () => { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const lastRunAt = readAutoDeleteLastRunAt(); + set({ autoDeleteLastRunAt: lastRunAt }); + return; + } + + const payload = await response.json().catch(() => ({})); + const enabled = typeof payload.autoDeleteEnabled === 'boolean' ? payload.autoDeleteEnabled : false; + const daysRaw = typeof payload.autoDeleteAfterDays === 'number' + ? payload.autoDeleteAfterDays + : Number(payload.autoDeleteAfterDays); + const normalizedDays = Number.isFinite(daysRaw) + ? Math.max(1, Math.min(365, daysRaw)) + : AUTO_DELETE_DEFAULT_DAYS; + const lastRunAt = readAutoDeleteLastRunAt(); + + set({ + autoDeleteEnabled: enabled, + autoDeleteAfterDays: normalizedDays, + autoDeleteLastRunAt: lastRunAt, + }); + } catch { + const lastRunAt = readAutoDeleteLastRunAt(); + set({ autoDeleteLastRunAt: lastRunAt }); + } + }, + + runAutoCleanup: async (sessionsOverride) => { + const { client, autoDeleteEnabled, autoDeleteAfterDays, currentSessionId } = get(); + if (!client || !autoDeleteEnabled || autoDeleteAfterDays <= 0) { + return; + } + if (autoDeleteRunning) { + return; + } + + const now = Date.now(); + const lastRunAt = readAutoDeleteLastRunAt(); + if (lastRunAt && now - lastRunAt < AUTO_DELETE_INTERVAL_MS) { + set({ autoDeleteLastRunAt: lastRunAt }); + return; + } + + const sessions = sessionsOverride ?? get().sessions; + if (!sessions.length) { + return; + } + + const candidateIds = buildAutoDeleteCandidates(sessions, currentSessionId, autoDeleteAfterDays, now); + if (candidateIds.length === 0) { + writeAutoDeleteLastRunAt(now); + set({ autoDeleteLastRunAt: now }); + return; + } + + autoDeleteRunning = true; + const deletedIds: string[] = []; + + try { + for (const id of candidateIds) { + try { + const response = await client.session.delete({ + path: { id }, + query: { directory: getWorkspaceFolder() }, + }); + if (response.data) { + deletedIds.push(id); + } + } catch { + // ignore individual delete failures + } + } + } finally { + autoDeleteRunning = false; + const finishedAt = Date.now(); + writeAutoDeleteLastRunAt(finishedAt); + set({ autoDeleteLastRunAt: finishedAt }); + } + + if (deletedIds.length > 0) { + set((state) => ({ + sessions: state.sessions.filter((session) => !deletedIds.includes(session.id)), + })); + } + }, + loadSessions: async () => { const { client } = get(); if (!client) return; @@ -74,6 +235,7 @@ export const useChatStore = create((set, get) => ({ (a, b) => (b.time?.created || 0) - (a.time?.created || 0) ); set({ sessions, isLoadingSessions: false }); + void get().runAutoCleanup(sessions); } catch (error) { console.error('Failed to load sessions:', error); set({ isLoadingSessions: false }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 866ec667..85c5dac8 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -281,6 +281,13 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.autoDeleteEnabled === 'boolean') { + result.autoDeleteEnabled = candidate.autoDeleteEnabled; + } + if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { + const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); + result.autoDeleteAfterDays = normalizedDays; + } const typography = sanitizeTypographySizesPartial(candidate.typographySizes); if (typography) {