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.
+
+
+
+
+
+
+ setAutoDeleteEnabled(event.target.checked)}
+ />
+ Enable auto-cleanup
+
+
+
+
+ {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) {