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") {
|
||||
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") {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -330,6 +330,8 @@ export interface SettingsPayload {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export type DesktopSettings = {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
};
|
||||
|
||||
export type DesktopSettingsApi = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user