feat: add session auto-cleanup settings and functionality
This commit is contained in:
@@ -136,6 +136,21 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||||
result_obj.insert("showReasoningTraces".to_string(), json!(b));
|
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
|
// Array fields
|
||||||
if let Some(arr) = obj.get("approvedDirectories") {
|
if let Some(arr) = obj.get("approvedDirectories") {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|||||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||||
import { useMessageSync } from '@/hooks/useMessageSync';
|
import { useMessageSync } from '@/hooks/useMessageSync';
|
||||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
@@ -152,6 +153,7 @@ function App({ apis }: AppProps) {
|
|||||||
useMessageSync();
|
useMessageSync();
|
||||||
|
|
||||||
useSessionStatusBootstrap();
|
useSessionStatusBootstrap();
|
||||||
|
useSessionAutoCleanup();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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 React from 'react';
|
||||||
import { AppearanceSettings } from './AppearanceSettings';
|
import { AppearanceSettings } from './AppearanceSettings';
|
||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
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"
|
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
|
||||||
>
|
>
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
|
<div className="border-t border-border/40 pt-6">
|
||||||
|
<SessionRetentionSettings />
|
||||||
|
</div>
|
||||||
{showAbout && (
|
{showAbout && (
|
||||||
<div className="border-t border-border/40 pt-6">
|
<div className="border-t border-border/40 pt-6">
|
||||||
<AboutSettings />
|
<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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -330,6 +330,8 @@ export interface SettingsPayload {
|
|||||||
securityScopedBookmarks?: string[];
|
securityScopedBookmarks?: string[];
|
||||||
pinnedDirectories?: string[];
|
pinnedDirectories?: string[];
|
||||||
showReasoningTraces?: boolean;
|
showReasoningTraces?: boolean;
|
||||||
|
autoDeleteEnabled?: boolean;
|
||||||
|
autoDeleteAfterDays?: number;
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { DesktopSettings } from '@/lib/desktop';
|
|||||||
|
|
||||||
type AppearanceSlice = {
|
type AppearanceSlice = {
|
||||||
showReasoningTraces: boolean;
|
showReasoningTraces: boolean;
|
||||||
|
autoDeleteEnabled: boolean;
|
||||||
|
autoDeleteAfterDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
@@ -17,6 +19,8 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
|
|
||||||
let previous: AppearanceSlice = {
|
let previous: AppearanceSlice = {
|
||||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||||
|
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||||
|
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pending: Partial<DesktopSettings> | null = null;
|
let pending: Partial<DesktopSettings> | null = null;
|
||||||
@@ -42,6 +46,8 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
useUIStore.subscribe((state) => {
|
useUIStore.subscribe((state) => {
|
||||||
const current: AppearanceSlice = {
|
const current: AppearanceSlice = {
|
||||||
showReasoningTraces: state.showReasoningTraces,
|
showReasoningTraces: state.showReasoningTraces,
|
||||||
|
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||||
|
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||||
};
|
};
|
||||||
|
|
||||||
const diff: Partial<DesktopSettings> = {};
|
const diff: Partial<DesktopSettings> = {};
|
||||||
@@ -49,6 +55,12 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||||
diff.showReasoningTraces = current.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;
|
previous = current;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type DesktopSettings = {
|
|||||||
securityScopedBookmarks?: string[];
|
securityScopedBookmarks?: string[];
|
||||||
pinnedDirectories?: string[];
|
pinnedDirectories?: string[];
|
||||||
showReasoningTraces?: boolean;
|
showReasoningTraces?: boolean;
|
||||||
|
autoDeleteEnabled?: boolean;
|
||||||
|
autoDeleteAfterDays?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DesktopSettingsApi = {
|
export type DesktopSettingsApi = {
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||||
store.setShowReasoningTraces(settings.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 => {
|
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||||
@@ -110,6 +119,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)));
|
const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0)));
|
||||||
if (uniqueIds.length === 0) {
|
if (uniqueIds.length === 0) {
|
||||||
return { deletedIds: [], failedIds: [] };
|
return { deletedIds: [], failedIds: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
const silent = options?.silent === true;
|
||||||
|
if (!silent) {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
}
|
||||||
const deletedIds: string[] = [];
|
const deletedIds: string[] = [];
|
||||||
const failedIds: string[] = [];
|
const failedIds: string[] = [];
|
||||||
const archivedIds = new Set<string>();
|
const archivedIds = new Set<string>();
|
||||||
@@ -743,10 +749,9 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
return {
|
return {
|
||||||
sessions: filteredSessions,
|
sessions: filteredSessions,
|
||||||
currentSessionId: nextCurrentId,
|
currentSessionId: nextCurrentId,
|
||||||
isLoading: false,
|
...(silent ? {} : { isLoading: false, error: errorMessage }),
|
||||||
worktreeMetadata: nextMetadata,
|
worktreeMetadata: nextMetadata,
|
||||||
availableWorktrees: nextAvailableWorktrees,
|
availableWorktrees: nextAvailableWorktrees,
|
||||||
error: errorMessage,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export interface SessionStore {
|
|||||||
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
|
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
|
||||||
|
|
||||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
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>;
|
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||||
shareSession: (id: string) => Promise<Session | null>;
|
shareSession: (id: string) => Promise<Session | null>;
|
||||||
unshareSession: (id: string) => Promise<Session | null>;
|
unshareSession: (id: string) => Promise<Session | null>;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ interface UIStore {
|
|||||||
eventStreamStatus: EventStreamStatus;
|
eventStreamStatus: EventStreamStatus;
|
||||||
eventStreamHint: string | null;
|
eventStreamHint: string | null;
|
||||||
showReasoningTraces: boolean;
|
showReasoningTraces: boolean;
|
||||||
|
autoDeleteEnabled: boolean;
|
||||||
|
autoDeleteAfterDays: number;
|
||||||
|
autoDeleteLastRunAt: number | null;
|
||||||
|
|
||||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -66,6 +69,9 @@ interface UIStore {
|
|||||||
setSidebarSection: (section: SidebarSection) => void;
|
setSidebarSection: (section: SidebarSection) => void;
|
||||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||||
setShowReasoningTraces: (value: boolean) => void;
|
setShowReasoningTraces: (value: boolean) => void;
|
||||||
|
setAutoDeleteEnabled: (value: boolean) => void;
|
||||||
|
setAutoDeleteAfterDays: (days: number) => void;
|
||||||
|
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
||||||
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
|
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
|
||||||
setFontSize: (size: number) => void;
|
setFontSize: (size: number) => void;
|
||||||
setPadding: (size: number) => void;
|
setPadding: (size: number) => void;
|
||||||
@@ -102,6 +108,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
eventStreamStatus: 'idle',
|
eventStreamStatus: 'idle',
|
||||||
eventStreamHint: null,
|
eventStreamHint: null,
|
||||||
showReasoningTraces: false,
|
showReasoningTraces: false,
|
||||||
|
autoDeleteEnabled: false,
|
||||||
|
autoDeleteAfterDays: 30,
|
||||||
|
autoDeleteLastRunAt: null,
|
||||||
toolCallExpansion: 'collapsed',
|
toolCallExpansion: 'collapsed',
|
||||||
fontSize: 100,
|
fontSize: 100,
|
||||||
padding: 100,
|
padding: 100,
|
||||||
@@ -222,6 +231,19 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ showReasoningTraces: value });
|
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) => {
|
setToolCallExpansion: (value) => {
|
||||||
set({ toolCallExpansion: value });
|
set({ toolCallExpansion: value });
|
||||||
},
|
},
|
||||||
@@ -399,6 +421,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||||
isSettingsDialogOpen: state.isSettingsDialogOpen,
|
isSettingsDialogOpen: state.isSettingsDialogOpen,
|
||||||
showReasoningTraces: state.showReasoningTraces,
|
showReasoningTraces: state.showReasoningTraces,
|
||||||
|
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||||
|
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||||
|
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
|
||||||
toolCallExpansion: state.toolCallExpansion,
|
toolCallExpansion: state.toolCallExpansion,
|
||||||
fontSize: state.fontSize,
|
fontSize: state.fontSize,
|
||||||
padding: state.padding,
|
padding: state.padding,
|
||||||
|
|||||||
@@ -5,6 +5,65 @@ import type { Session, Message, Part } from '@opencode-ai/sdk';
|
|||||||
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
|
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
|
||||||
const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
|
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 {
|
interface MessageRecord {
|
||||||
info: Message;
|
info: Message;
|
||||||
parts: Part[];
|
parts: Part[];
|
||||||
@@ -19,6 +78,9 @@ interface ChatState {
|
|||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
isLoadingSessions: boolean;
|
isLoadingSessions: boolean;
|
||||||
|
autoDeleteEnabled: boolean;
|
||||||
|
autoDeleteAfterDays: number;
|
||||||
|
autoDeleteLastRunAt: number | null;
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
messages: Map<string, MessageRecord[]>;
|
messages: Map<string, MessageRecord[]>;
|
||||||
@@ -28,6 +90,8 @@ interface ChatState {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
loadAutoDeleteSettings: () => Promise<void>;
|
||||||
|
runAutoCleanup: (sessionsOverride?: Session[]) => Promise<void>;
|
||||||
loadSessions: () => Promise<void>;
|
loadSessions: () => Promise<void>;
|
||||||
createSession: () => Promise<string | null>;
|
createSession: () => Promise<string | null>;
|
||||||
selectSession: (sessionId: string) => Promise<void>;
|
selectSession: (sessionId: string) => Promise<void>;
|
||||||
@@ -42,6 +106,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
currentSessionId: null,
|
currentSessionId: null,
|
||||||
isLoadingSessions: false,
|
isLoadingSessions: false,
|
||||||
|
autoDeleteEnabled: false,
|
||||||
|
autoDeleteAfterDays: AUTO_DELETE_DEFAULT_DAYS,
|
||||||
|
autoDeleteLastRunAt: readAutoDeleteLastRunAt(),
|
||||||
messages: new Map(),
|
messages: new Map(),
|
||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
isSending: false,
|
isSending: false,
|
||||||
@@ -55,6 +122,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
||||||
set({ client, isConnected: true });
|
set({ client, isConnected: true });
|
||||||
|
await get().loadAutoDeleteSettings();
|
||||||
await get().loadSessions();
|
await get().loadSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect to OpenCode API:', 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 () => {
|
loadSessions: async () => {
|
||||||
const { client } = get();
|
const { client } = get();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
@@ -74,6 +235,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
(a, b) => (b.time?.created || 0) - (a.time?.created || 0)
|
(a, b) => (b.time?.created || 0) - (a.time?.created || 0)
|
||||||
);
|
);
|
||||||
set({ sessions, isLoadingSessions: false });
|
set({ sessions, isLoadingSessions: false });
|
||||||
|
void get().runAutoCleanup(sessions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load sessions:', error);
|
console.error('Failed to load sessions:', error);
|
||||||
set({ isLoadingSessions: false });
|
set({ isLoadingSessions: false });
|
||||||
|
|||||||
@@ -281,6 +281,13 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
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);
|
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
||||||
if (typography) {
|
if (typography) {
|
||||||
|
|||||||
Reference in New Issue
Block a user