Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions src-tauri/src/codex_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ pub(crate) struct CodexSessionPage {
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteCodexSessionsRequest {
session_ids: Vec<String>,
#[serde(default)]
skip_backup: bool,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -223,7 +225,7 @@ pub(crate) async fn delete_codex_sessions(
.map_err(|error| format!("Failed to get user directory: {error}"))?;
let codex_home = resolve_codex_home(&user_home);
tauri::async_runtime::spawn_blocking(move || {
delete_codex_sessions_from_home(&codex_home, request.session_ids)
delete_codex_sessions_from_home_with_options(&codex_home, request.session_ids, request.skip_backup)
})
.await
.map_err(|error| format!("Failed to delete Codex session task: {error}"))?
Expand Down Expand Up @@ -823,9 +825,18 @@ fn session_identity_key(value: &str) -> String {
}
}

#[cfg(test)]
fn delete_codex_sessions_from_home(
codex_home: &Path,
session_ids: Vec<String>,
) -> Result<CodexSessionDeleteBatchResult, String> {
delete_codex_sessions_from_home_with_options(codex_home, session_ids, false)
}

fn delete_codex_sessions_from_home_with_options(
codex_home: &Path,
session_ids: Vec<String>,
skip_backup: bool,
) -> Result<CodexSessionDeleteBatchResult, String> {
let _guard = CODEX_SESSION_WRITE_LOCK
.lock()
Expand Down Expand Up @@ -853,7 +864,7 @@ fn delete_codex_sessions_from_home(
}
let mut results = Vec::new();
for id in unique {
results.push(delete_one_session(codex_home, &database_paths, &id));
results.push(delete_one_session(codex_home, &database_paths, &id, skip_backup));
}
let deleted_count = results
.iter()
Expand All @@ -871,6 +882,7 @@ fn delete_one_session(
codex_home: &Path,
database_paths: &[PathBuf],
session_id: &str,
skip_backup: bool,
) -> CodexSessionDeleteResult {
let outcome = (|| -> Result<CodexSessionDeleteResult, String> {
let mut plans = Vec::new();
Expand All @@ -887,7 +899,11 @@ fn delete_one_session(
backup_path: None,
});
}
let backup_dir = create_delete_backup(codex_home, session_id, &plans)?;
let backup_dir = if skip_backup {
None
} else {
Some(create_delete_backup(codex_home, session_id, &plans)?)
};
for (committed_databases, plan) in plans.iter().enumerate() {
if let Err(error) = apply_database_delete_plan(plan, session_id) {
let status = if committed_databases > 0 {
Expand All @@ -901,7 +917,7 @@ fn delete_one_session(
message: format!(
"Committed {committed_databases} database transactions; deletion failed for the remaining databases: {error}"
),
backup_path: Some(backup_dir.to_string_lossy().to_string()),
backup_path: backup_dir.as_ref().map(|dir| dir.to_string_lossy().to_string()),
});
}
}
Expand Down Expand Up @@ -946,7 +962,7 @@ fn delete_one_session(
session_id: session_id.to_string(),
status: status.to_string(),
message,
backup_path: Some(backup_dir.to_string_lossy().to_string()),
backup_path: backup_dir.as_ref().map(|dir| dir.to_string_lossy().to_string()),
})
})();
outcome.unwrap_or_else(|message| CodexSessionDeleteResult {
Expand Down Expand Up @@ -2625,6 +2641,25 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}

#[test]
fn deletion_without_backup_removes_rows_and_rollout_without_creating_backup_dir() {
let root = test_root("delete-no-backup");
let rollout = root.join("sessions/2026/rollout.jsonl");
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
fs::write(&rollout, "session\n").unwrap();
create_threads_db(&root.join("state_5.sqlite"), &rollout);

let result =
delete_codex_sessions_from_home_with_options(&root, vec!["thread-1".to_string()], true)
.unwrap();
assert_eq!(result.deleted_count, 1);
assert_eq!(result.results[0].status, "deleted");
assert!(result.results[0].backup_path.is_none());
assert!(!rollout.exists());
assert!(!root.join("backups/cpa-codex-session-deletes").exists());
fs::remove_dir_all(root).unwrap();
}

#[test]
fn deletion_cleans_known_relations_and_preserves_outside_rollouts() {
let root = test_root("delete-relations");
Expand Down
20 changes: 16 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { closePluginWebview } from './services/pluginWebview';
import { WindowFrame } from './components/WindowFrame';
import { MessageNotice } from './appNotice';
import { ThemeSelector } from './components/ThemeSelector';
import { Fragment, useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import {
Bot,
Check,
Expand Down Expand Up @@ -59,7 +59,7 @@ import {
import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, requestAppNavigation } from './navigation';
import { canOpenAppPage, requestAppNavigation, resetWorkspaceScroll } from './navigation';
import { useThemePreference } from './theme';
import { usePrivacyMode } from './privacy';
import { Dialog } from './components/Dialog';
Expand Down Expand Up @@ -201,10 +201,15 @@ function AppContent() {
].filter(Boolean).join(' · ');
useEffect(() => {
if (!canOpenAppPage(active, coreReady)) {
resetWorkspaceScroll();
setActive('home');
}
}, [active, coreReady]);

useLayoutEffect(() => {
resetWorkspaceScroll();
}, [active]);

useEffect(() => {
if (!coreReady) void closePluginWebview().catch(() => undefined);
}, [coreReady]);
Expand Down Expand Up @@ -299,8 +304,15 @@ function AppContent() {
if (!canOpenAppPage(pageId, coreReady)) {
return;
}
if (pageId !== active) requestAppNavigation(() => setActive(pageId));
else if (isPluginRoute(pageId)) setPluginReload((value) => value + 1);
if (pageId !== active) {
requestAppNavigation(() => {
resetWorkspaceScroll();
setActive(pageId);
});
} else if (isPluginRoute(pageId)) {
resetWorkspaceScroll();
setPluginReload((value) => value + 1);
}
}, [active, coreReady]);

useEffect(() => {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,12 @@ export const jaOverrides = {
'agents.sessions.deleteManyTitle': '{count} 件のセッションを削除しますか?',
'agents.sessions.deleteOneDescription': 'バックアップを作成してから「{title}」と既知の関連記録を削除します。アプリ内で元に戻すことはできません。',
'agents.sessions.deleteManyDescription': 'バックアップを作成してから、次の {count} 件と既知の関連記録を削除します:{preview}{extra}',
'agents.sessions.deleteOneDirectDescription': 'バックアップを作成せずに「{title}」と既知の関連記録を直接削除します。アプリ内で元に戻すことはできません。',
'agents.sessions.deleteManyDirectDescription': 'バックアップを作成せずに、次の {count} 件と既知の関連記録を直接削除します:{preview}{extra}',
'agents.sessions.deleteMore': '、ほか {count} 件',
'agents.sessions.deleteWithoutBackup': 'バックアップを作成せずに直接削除',
'agents.sessions.confirmDelete': 'バックアップして削除',
'agents.sessions.confirmDirectDelete': '直接削除',
'agents.sessions.deleteResult': '{deleted} 件を削除、{failed} 件失敗。{details}{backup}',
'agents.sessions.deleteFailed': 'セッションの削除に失敗しました:{error}',
'agents.sessions.backupAt': 'バックアップ先:{path}',
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,12 @@ export const en: Record<MessageKey, string> = {
'agents.sessions.deleteManyTitle': 'Delete {count} sessions?',
'agents.sessions.deleteOneDescription': 'A backup will be created before “{title}” and its known related records are deleted. There is no in-app undo.',
'agents.sessions.deleteManyDescription': 'A backup will be created before these {count} sessions and their known related records are deleted: {preview}{extra}',
'agents.sessions.deleteOneDirectDescription': '“{title}” and its known related records will be deleted permanently without creating a backup. There is no in-app undo.',
'agents.sessions.deleteManyDirectDescription': 'These {count} sessions and their known related records will be deleted permanently without creating a backup: {preview}{extra}',
'agents.sessions.deleteMore': ', plus {count} more',
'agents.sessions.deleteWithoutBackup': 'Delete directly without creating a backup',
'agents.sessions.confirmDelete': 'Back up and delete',
'agents.sessions.confirmDirectDelete': 'Delete permanently',
'agents.sessions.deleteResult': 'Deleted {deleted}; failed {failed}. {details}{backup}',
'agents.sessions.deleteFailed': 'Failed to delete sessions: {error}',
'agents.sessions.backupAt': 'Backup: {path}',
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,12 @@ export const zhCN = {
'agents.sessions.deleteManyTitle': '删除 {count} 个会话?',
'agents.sessions.deleteOneDescription': '将先创建备份,再删除“{title}”及其已知关联记录。此操作不提供界面内撤销。',
'agents.sessions.deleteManyDescription': '将先创建备份,再删除以下 {count} 个会话及其已知关联记录:{preview}{extra}',
'agents.sessions.deleteOneDirectDescription': '将直接删除“{title}”及其已知关联记录,不创建备份。此操作不提供界面内撤销。',
'agents.sessions.deleteManyDirectDescription': '将直接删除以下 {count} 个会话及其已知关联记录,不创建备份:{preview}{extra}',
'agents.sessions.deleteMore': ',另有 {count} 个',
'agents.sessions.deleteWithoutBackup': '直接删除,不创建备份',
'agents.sessions.confirmDelete': '备份并删除',
'agents.sessions.confirmDirectDelete': '直接删除',
'agents.sessions.deleteResult': '已删除 {deleted} 个,失败 {failed} 个。{details}{backup}',
'agents.sessions.deleteFailed': '删除会话失败:{error}',
'agents.sessions.backupAt': '备份位置:{path}',
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,9 @@ export const zhTW: Record<MessageKey, string> = {
'home.runtime.unknownHint': '核心狀態未知,讀取成功前無法啟動或重新啟動。',
'home.runtime.errorDetails': '錯誤詳細資訊',
'home.runtime.installHint': '安裝核心後即可啟動;鎖定的頁面會在核心執行後開放。',
'agents.sessions.deleteOneDirectDescription': '將直接刪除「{title}」及其已知關聯記錄,不建立備份。此操作不提供介面內復原。',
'agents.sessions.deleteManyDirectDescription': '將直接刪除以下 {count} 個工作階段及其已知關聯記錄,不建立備份:{preview}{extra}',
'agents.sessions.deleteWithoutBackup': '直接刪除,不建立備份',
'agents.sessions.confirmDirectDelete': '直接刪除',
};
export const ja: Record<MessageKey, string> = jaOverrides;
8 changes: 7 additions & 1 deletion src/mocks/browserMockRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1613,9 +1613,15 @@ export function createBrowserMockRuntime(
case 'delete_codex_sessions': {
const request = asObject(payload.request);
const ids = asArray(request.sessionIds).map(String);
const skipBackup = Boolean(request.skipBackup);
state.codexSessions = state.codexSessions.filter((session) => !ids.includes(session.id));
return {
results: ids.map((id) => ({ sessionId: id, status: 'deleted', message: 'Deleted by Browser Mock', backupPath: 'C:\\Users\\Mock\\.codex\\backups\\mock.zip' })),
results: ids.map((id) => ({
sessionId: id,
status: 'deleted',
message: 'Deleted by Browser Mock',
backupPath: skipBackup ? null : 'C:\\Users\\Mock\\.codex\\backups\\mock.zip',
})),
deletedCount: ids.length,
failedCount: 0,
};
Expand Down
36 changes: 36 additions & 0 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,39 @@ export function requestAppNavigation(navigate: () => void) {
});
if (window.dispatchEvent(event)) navigate();
}

const SCROLL_CONTAINER_SELECTORS = [
'.immersive-window',
'.window-content',
'.app-shell',
'.workspace',
'.content',
'.agent-config-scroll-region',
] as const;

export function resetWorkspaceScroll(root: ParentNode | null = typeof document !== 'undefined' ? document : null) {
if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
try {
window.scrollTo(0, 0);
} catch {
// Ignore non-scrolling window environments in tests.
}
}
if (typeof document !== 'undefined') {
if (document.documentElement) {
document.documentElement.scrollTop = 0;
document.documentElement.scrollLeft = 0;
}
if (document.body) {
document.body.scrollTop = 0;
document.body.scrollLeft = 0;
}
}
if (!root || typeof root.querySelectorAll !== 'function') return;
for (const selector of SCROLL_CONTAINER_SELECTORS) {
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
element.scrollTop = 0;
element.scrollLeft = 0;
}
}
}
23 changes: 19 additions & 4 deletions src/pages/CodexSessionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type DeleteConfirmation = {
ids: string[];
title: string;
description: string;
directDescription: string;
};

const PAGE_SIZE = 50;
Expand All @@ -53,6 +54,7 @@ let sessionViewCache = {
offset: 0,
selectionMode: false,
selectedIds: new Set<string>(),
skipDeleteBackup: false,
};

function errorMessage(error: unknown): string {
Expand All @@ -68,6 +70,7 @@ export function CodexSessionsPanel() {
const [selectionMode, setSelectionMode] = useState(() => sessionViewCache.selectionMode);
const [selectedIds, setSelectedIds] = useState(() => new Set(sessionViewCache.selectedIds));
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation | null>(null);
const [skipDeleteBackup, setSkipDeleteBackup] = useState(() => sessionViewCache.skipDeleteBackup);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [cleanupPreview, setCleanupPreview] = useState<SessionIndexCleanupPreview | null>(null);
const [cleanupSelectedIds, setCleanupSelectedIds] = useState<Set<string>>(() => new Set());
Expand All @@ -94,8 +97,9 @@ export function CodexSessionsPanel() {
offset: page?.offset ?? initialOffsetRef.current,
selectionMode,
selectedIds: new Set(selectedIds),
skipDeleteBackup,
};
}, [page, selectionMode, selectedIds]);
}, [page, selectionMode, selectedIds, skipDeleteBackup]);

const loadPage = useCallback(async (offset = 0, silent = false) => {
if (!mountedRef.current) return null;
Expand Down Expand Up @@ -185,6 +189,13 @@ export function CodexSessionsPanel() {
preview,
extra: extra > 0 ? t('agents.sessions.deleteMore', { count: extra }) : '',
}),
directDescription: targets.length === 1
? t('agents.sessions.deleteOneDirectDescription', { title: preview })
: t('agents.sessions.deleteManyDirectDescription', {
count: targets.length,
preview,
extra: extra > 0 ? t('agents.sessions.deleteMore', { count: extra }) : '',
}),
});
};

Expand All @@ -196,7 +207,7 @@ export function CodexSessionsPanel() {
setNotice(null);
try {
const result = await deleteCodexSessions({
request: { sessionIds: request.ids },
request: { sessionIds: request.ids, skipBackup: skipDeleteBackup },
});
const failed = result.results.filter((item) => !['deleted', 'partial'].includes(item.status));
const partial = result.results.filter((item) => item.status === 'partial');
Expand Down Expand Up @@ -509,10 +520,14 @@ export function CodexSessionsPanel() {
{deleteConfirmation ? (
<Dialog alert className="config-dialog codex-session-dialog" labelledBy="codex-session-delete-title" onClose={() => setDeleteConfirmation(null)} closeDisabled={operation === 'delete'}>
<div className="config-dialog-heading"><div><TriangleAlert size={19} /><h2 id="codex-session-delete-title">{deleteConfirmation.title}</h2></div></div>
<p>{deleteConfirmation.description}</p>
<p>{skipDeleteBackup ? deleteConfirmation.directDescription : deleteConfirmation.description}</p>
<label className="codex-session-delete-direct">
<Checkbox checked={skipDeleteBackup} onChange={(event) => setSkipDeleteBackup(event.currentTarget.checked)} />
<span>{t('agents.sessions.deleteWithoutBackup')}</span>
</label>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={() => setDeleteConfirmation(null)}>{t('common.cancel')}</button>
<button type="button" className="danger-button" onClick={() => void confirmDelete()}><Trash2 size={15} />{t('agents.sessions.confirmDelete')}</button>
<button type="button" className="danger-button" onClick={() => void confirmDelete()}><Trash2 size={15} />{t(skipDeleteBackup ? 'agents.sessions.confirmDirectDelete' : 'agents.sessions.confirmDelete')}</button>
</div>
</Dialog>
) : null}
Expand Down
17 changes: 14 additions & 3 deletions src/pages/ConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from '../services/coreSettings';
import type { CoreConfigSettings, CoreApiKey, SoftwareSettings, CloseBehavior, CoreTlsSettings } from '../services/coreSettings';
import { CoolingToggle } from '../components/settings/CoolingToggle';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { open } from '../services/platform';
import {
AlertCircle,
Expand Down Expand Up @@ -73,6 +73,7 @@ import {
settingsCategories, settingsTemplateGroups,
allSettingsTemplateGroups, settingsMessages, takeRequestedSettingField, type SettingsCategory,
} from '../services/settingsNavigation';
import { resetWorkspaceScroll } from '../navigation';
import { Select } from '../components/Select';

type ConfigAction =
Expand Down Expand Up @@ -220,6 +221,12 @@ export function ConfigPanelPage() {
const details = target.closest<HTMLDetailsElement>('.template-config-custom') ?? target.querySelector<HTMLDetailsElement>('.template-config-custom');
if (details) details.open = true;
target.scrollIntoView({ block: 'center', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
for (const ancestor of document.querySelectorAll<HTMLElement>('.immersive-window, .window-content, .app-shell')) {
ancestor.scrollTop = 0;
ancestor.scrollLeft = 0;
}
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
const control = target.querySelector<HTMLElement>('input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')
?? target.querySelector<HTMLElement>('.template-config-custom > summary');
(control ?? target).focus({ preventScroll: true });
Expand Down Expand Up @@ -994,16 +1001,20 @@ export function ConfigPanelPage() {
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
const managementSecretBusy = busyAction === 'management-secret';
const loggingSettingsBusy = busyAction === 'logging';
const activateConfigSubpage = (subpage: ConfigSubpage) => {
const activateConfigSubpage = (subpage: ConfigSubpage, preserveScroll = false) => {
setSettingsSearch('');
if (!preserveScroll) resetWorkspaceScroll();
setActiveSubpage(subpage);
if (subpage === 'requests') {
setSensitiveWordsVisited(true);
}
if (subpage === 'aliases') setAliasesVisited(true);
};
useLayoutEffect(() => {
if (!pendingDestination) resetWorkspaceScroll();
}, [activeSubpage, pendingDestination]);
const navigateToSetting = (destination: SettingDestination) => {
activateConfigSubpage(destination.category);
activateConfigSubpage(destination.category, true);
setPendingDestination(destination);
};
useEffect(() => {
Expand Down
Loading
Loading