feat: add session auto-cleanup settings and functionality

This commit is contained in:
Bohdan Triapitsyn
2025-12-20 02:06:00 +02:00
parent 79ed6abab7
commit 53c103a222
14 changed files with 571 additions and 5 deletions
@@ -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") {
+2
View File
@@ -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) => {
@@ -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 (
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Session retention</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Automatically delete inactive sessions based on their last activity.<br />
You can also run a one-time cleanup without enabling auto-cleanup.<br />
Keeps the most recent 5 sessions, and never deletes shared sessions.
</TooltipContent>
</Tooltip>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary"
checked={autoDeleteEnabled}
onChange={(event) => setAutoDeleteEnabled(event.target.checked)}
/>
<span className="typography-ui-header font-semibold text-foreground">Enable auto-cleanup</span>
</label>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
{isMobile ? (
<input
type="number"
inputMode="numeric"
value={mobileDraftDays}
onChange={(event) => {
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"
/>
) : (
<NumberInput
value={autoDeleteAfterDays}
onValueChange={setAutoDeleteAfterDays}
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
aria-label="Retention period in days"
/>
)}
<span className="typography-ui-label text-muted-foreground">days since last activity</span>
</div>
<ButtonSmall
type="button"
variant="outline"
onClick={handleRunCleanup}
disabled={isRunning}
>
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
</ButtonSmall>
</div>
<div className="typography-meta text-muted-foreground">
Eligible for deletion right now: {pendingCount}
</div>
</div>
);
};
@@ -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"
>
<AppearanceSettings />
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
@@ -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<CleanupResult> => {
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,
};
};
+2
View File
@@ -330,6 +330,8 @@ export interface SettingsPayload {
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
[key: string]: unknown;
}
+12
View File
@@ -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<DesktopSettings> | 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<DesktopSettings> = {};
@@ -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;
+2
View File
@@ -39,6 +39,8 @@ export type DesktopSettings = {
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
};
export type DesktopSettingsApi = {
+15
View File
@@ -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;
};
+9 -4
View File
@@ -653,13 +653,19 @@ export const useSessionStore = create<SessionStore>()(
}
},
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<string>();
@@ -743,10 +749,9 @@ export const useSessionStore = create<SessionStore>()(
return {
sessions: filteredSessions,
currentSessionId: nextCurrentId,
isLoading: false,
...(silent ? {} : { isLoading: false, error: errorMessage }),
worktreeMetadata: nextMetadata,
availableWorktrees: nextAvailableWorktrees,
error: errorMessage,
};
});
+1 -1
View File
@@ -104,7 +104,7 @@ export interface SessionStore {
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
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<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
+25
View File
@@ -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<UIStore>()(
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<UIStore>()(
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<UIStore>()(
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,
+162
View File
@@ -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<string, MessageRecord[]>;
@@ -28,6 +90,8 @@ interface ChatState {
// Actions
initialize: () => Promise<void>;
loadAutoDeleteSettings: () => Promise<void>;
runAutoCleanup: (sessionsOverride?: Session[]) => Promise<void>;
loadSessions: () => Promise<void>;
createSession: () => Promise<string | null>;
selectSession: (sessionId: string) => Promise<void>;
@@ -42,6 +106,9 @@ export const useChatStore = create<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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 });
+7
View File
@@ -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) {