feat: add Codex session management and automatic restoration
This commit is contained in:
1 parent
50b35ae6bd
commit
4e8125b94a
12 files changed
+4433
-77
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
7.2.99
|
||||
7.2.100
|
||||
File diff suppressed because it is too large.
Load diff
+39
-2
@@ -1,5 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod codex_sessions;
|
||||
mod usage;
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
@@ -439,6 +440,7 @@ struct GuiConfigFile {
|
||||
usage_statistics_enabled: bool,
|
||||
plugins_enabled: bool,
|
||||
routing_strategy: String,
|
||||
codex_session_repair_on_launch: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
@@ -501,6 +503,7 @@ impl Default for GuiConfigFile {
|
||||
usage_statistics_enabled: true,
|
||||
plugins_enabled: false,
|
||||
routing_strategy: "round-robin".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +518,7 @@ struct GuiConfigPresence {
|
||||
auth_dir: Option<String>,
|
||||
api_keys: Option<Vec<GuiApiKeyInput>>,
|
||||
management_secret_key: Option<String>,
|
||||
codex_session_repair_on_launch: Option<bool>,
|
||||
usage_statistics_enabled: Option<bool>,
|
||||
plugins_enabled: Option<bool>,
|
||||
routing_strategy: Option<String>,
|
||||
@@ -1154,6 +1158,13 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_codex_session_repair_on_launch(&self, enabled: bool) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.codex_session_repair_on_launch = enabled;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locale(&self, locale: String) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.locale = normalize_app_locale(&locale).to_string();
|
||||
@@ -1728,7 +1739,7 @@ async fn update_agent_config(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn launch_agent(
|
||||
async fn launch_agent(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
@@ -1750,6 +1761,14 @@ fn launch_agent(
|
||||
status.modification_enabled,
|
||||
&status.modification_state,
|
||||
)?;
|
||||
if client == AgentClient::Codex && config.codex_session_repair_on_launch {
|
||||
let repair_home = home.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
codex_sessions::repair_before_codex_launch(&repair_home)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("启动前会话修复任务失败: {error}"))??;
|
||||
}
|
||||
let requested_target = target
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
@@ -10214,6 +10233,9 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
if presence.locale.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
if presence.codex_session_repair_on_launch.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
changed |= sanitize_gui_config(&mut config)?;
|
||||
validate_gui_config(&config)?;
|
||||
if changed {
|
||||
@@ -10521,6 +10543,10 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul
|
||||
),
|
||||
("plugins-enabled", value(config.plugins_enabled)),
|
||||
("routing-strategy", value(config.routing_strategy.as_str())),
|
||||
(
|
||||
"codex-session-repair-on-launch",
|
||||
value(config.codex_session_repair_on_launch),
|
||||
),
|
||||
] {
|
||||
set_codex_table_item(root, key, item);
|
||||
}
|
||||
@@ -12792,7 +12818,14 @@ fn main() {
|
||||
usage::get_usage_events,
|
||||
start_core_process,
|
||||
stop_core_process,
|
||||
restart_core_process
|
||||
restart_core_process,
|
||||
codex_sessions::list_codex_sessions,
|
||||
codex_sessions::delete_codex_sessions,
|
||||
codex_sessions::repair_codex_session_metadata,
|
||||
codex_sessions::preview_codex_session_index_cleanup,
|
||||
codex_sessions::apply_codex_session_index_cleanup,
|
||||
codex_sessions::get_codex_session_repair_on_launch,
|
||||
codex_sessions::set_codex_session_repair_on_launch
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build app");
|
||||
@@ -13092,6 +13125,7 @@ mod tests {
|
||||
auth_dir: path_to_string(&home.join("custom-auth")),
|
||||
management_secret_key: "custom-secret".to_string(),
|
||||
usage_statistics_enabled: false,
|
||||
codex_session_repair_on_launch: true,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
@@ -13104,6 +13138,7 @@ mod tests {
|
||||
assert!(content.contains("host = \"0.0.0.0\""));
|
||||
assert!(content.contains("management-secret-key = \"custom-secret\""));
|
||||
assert!(content.contains("usage-statistics-enabled = false"));
|
||||
assert!(content.contains("codex-session-repair-on-launch = true"));
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
@@ -13169,6 +13204,7 @@ mod tests {
|
||||
assert!(content.contains("management-secret-key = \"123456\""));
|
||||
assert!(content.contains("plugins-enabled = false"));
|
||||
assert!(content.contains("routing-strategy = \"round-robin\""));
|
||||
assert!(content.contains("codex-session-repair-on-launch = false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -14960,6 +14996,7 @@ custom_option = "keep-original"
|
||||
usage_statistics_enabled: false,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
};
|
||||
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
|
||||
|
||||
|
||||
@@ -1385,6 +1385,7 @@ mod tests {
|
||||
usage_statistics_enabled: true,
|
||||
plugins_enabled: false,
|
||||
routing_strategy: "round-robin".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
};
|
||||
let record = normalize_usage_record(
|
||||
serde_json::json!({
|
||||
|
||||
+68
-1
@@ -377,7 +377,74 @@ export const jaOverrides = {
|
||||
'agents.tabs.core': 'コア設定',
|
||||
'agents.tabs.sessions': 'セッション管理',
|
||||
'agents.sessions.title': 'Codex セッション管理',
|
||||
'agents.sessions.description': 'セッションの一覧と管理機能はここに表示されます。',
|
||||
'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。',
|
||||
'agents.sessions.refresh': '更新',
|
||||
'agents.sessions.currentPage': '現在のページ',
|
||||
'agents.sessions.active': '未アーカイブ',
|
||||
'agents.sessions.archived': 'アーカイブ済み',
|
||||
'agents.sessions.database': 'データベース',
|
||||
'agents.sessions.databaseMissing': 'セッションデータベースが見つかりません',
|
||||
'agents.sessions.repairTitle': '過去のセッションを復元',
|
||||
'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。',
|
||||
'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元',
|
||||
'agents.sessions.autoRestoreDescription': 'このアプリから Codex を起動するときにセッションを自動同期します。',
|
||||
'agents.sessions.autoRepairEnabled': '起動前の自動復元を有効にしました。',
|
||||
'agents.sessions.autoRepairDisabled': '起動前の自動復元を無効にしました。',
|
||||
'agents.sessions.settingLoading': '読み込み中',
|
||||
'agents.sessions.settingOn': 'オン',
|
||||
'agents.sessions.settingOff': 'オフ',
|
||||
'agents.sessions.settingLoadFailed': '自動復元設定の読み込みに失敗しました:{error}',
|
||||
'agents.sessions.applyManagedFirst': '先に Codex の CPA 管理設定を適用してください',
|
||||
'agents.sessions.repairNow': '今すぐ復元',
|
||||
'agents.sessions.repairing': '復元中',
|
||||
'agents.sessions.loadFailed': 'Codex セッションの読み込みに失敗しました:{error}',
|
||||
'agents.sessions.localSessions': 'ローカルセッション',
|
||||
'agents.sessions.pageDescription': '{page} ページ目・1 ページ最大 {size} 件',
|
||||
'agents.sessions.selected': '{count} 件選択中',
|
||||
'agents.sessions.selectAll': 'このページをすべて選択',
|
||||
'agents.sessions.clearSelection': '選択を解除',
|
||||
'agents.sessions.deleteSelected': '選択項目を削除',
|
||||
'agents.sessions.multiSelect': '複数選択',
|
||||
'agents.sessions.loading': 'ローカルセッションをスキャンしています…',
|
||||
'agents.sessions.empty': 'Codex セッションが見つかりません。',
|
||||
'agents.sessions.selectSession': 'セッション {title} を選択',
|
||||
'agents.sessions.untitled': '無題のセッション',
|
||||
'agents.sessions.noProject': 'プロジェクトパスなし',
|
||||
'agents.sessions.noProvider': '不明な Provider',
|
||||
'agents.sessions.noTime': '更新時刻不明',
|
||||
'agents.sessions.activeBadge': '未アーカイブ',
|
||||
'agents.sessions.archivedBadge': 'アーカイブ済み',
|
||||
'agents.sessions.previousPage': '前のページ',
|
||||
'agents.sessions.nextPage': '次のページ',
|
||||
'agents.sessions.pageNumber': '{page} ページ目',
|
||||
'agents.sessions.deleteOneTitle': 'このセッションを削除しますか?',
|
||||
'agents.sessions.deleteManyTitle': '{count} 件のセッションを削除しますか?',
|
||||
'agents.sessions.deleteOneDescription': 'バックアップを作成してから「{title}」と既知の関連記録を削除します。アプリ内で元に戻すことはできません。',
|
||||
'agents.sessions.deleteManyDescription': 'バックアップを作成してから、次の {count} 件と既知の関連記録を削除します:{preview}{extra}',
|
||||
'agents.sessions.deleteMore': '、ほか {count} 件',
|
||||
'agents.sessions.confirmDelete': 'バックアップして削除',
|
||||
'agents.sessions.deleteResult': '{deleted} 件を削除、{failed} 件失敗。{details}{backup}',
|
||||
'agents.sessions.deleteFailed': 'セッションの削除に失敗しました:{error}',
|
||||
'agents.sessions.backupAt': 'バックアップ先:{path}',
|
||||
'agents.sessions.settingFailed': '自動復元設定の保存に失敗しました:{error}',
|
||||
'agents.sessions.repairResult': '過去のセッションを {provider} に復元しました:rollout {files} ファイルを移行、データベース {rows} 行を更新、使用中の {skipped} ファイルをスキップしました。',
|
||||
'agents.sessions.repairFailed': '過去のセッションの復元に失敗しました:{error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': 'セッションをスキャン中',
|
||||
'agents.sessions.progress.backingUp': 'バックアップを作成中',
|
||||
'agents.sessions.progress.rewriting': '過去のセッションを移行中',
|
||||
'agents.sessions.progress.updatingDatabase': 'データベースを同期中',
|
||||
'agents.sessions.progress.complete': '復元完了',
|
||||
'agents.sessions.scanIndex': '無効なセッション記録を確認',
|
||||
'agents.sessions.scanningIndex': '無効な記録を確認中',
|
||||
'agents.sessions.cleanupNoCandidates': '安全に削除できる無効なセッション記録はありません。',
|
||||
'agents.sessions.cleanupPreviewFailed': '無効なセッション記録の確認に失敗しました:{error}',
|
||||
'agents.sessions.cleanupTitle': '削除可能な無効な索引',
|
||||
'agents.sessions.cleanupDescription': 'session_index.jsonl のみに存在し、実際のセッション元がなくなった記録が {count} 件見つかりました。初期状態では未選択です。Codex と ChatGPT を終了し、項目ごとに確認して削除してください。不明な形式の記録は保持されます。',
|
||||
'agents.sessions.cleanupSelectAll': '候補をすべて選択',
|
||||
'agents.sessions.cleanupConfirm': '選択した {count} 件を削除',
|
||||
'agents.sessions.cleanupResult': '無効なセッション索引を {count} 件削除しました。{backup}',
|
||||
'agents.sessions.cleanupFailed': '無効なセッション索引の削除に失敗しました:{error}',
|
||||
'agents.localClients': 'ローカルクライアント',
|
||||
'agents.selectClient': '管理するエージェントを選択',
|
||||
'agents.installStatus': 'インストール状態',
|
||||
|
||||
+136
-2
@@ -380,7 +380,74 @@ export const zhCN = {
|
||||
'agents.tabs.core': '核心配置',
|
||||
'agents.tabs.sessions': '会话管理',
|
||||
'agents.sessions.title': 'Codex 会话管理',
|
||||
'agents.sessions.description': '会话列表与管理功能将在这里显示。',
|
||||
'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。',
|
||||
'agents.sessions.refresh': '刷新',
|
||||
'agents.sessions.currentPage': '当前页',
|
||||
'agents.sessions.active': '未归档',
|
||||
'agents.sessions.archived': '已归档',
|
||||
'agents.sessions.database': '数据库来源',
|
||||
'agents.sessions.databaseMissing': '未发现会话数据库',
|
||||
'agents.sessions.repairTitle': '恢复历史会话',
|
||||
'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话',
|
||||
'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话',
|
||||
'agents.sessions.autoRestoreDescription': '从本软件启动 Codex 时自动同步会话',
|
||||
'agents.sessions.autoRepairEnabled': '已开启启动前自动恢复历史会话。',
|
||||
'agents.sessions.autoRepairDisabled': '已关闭启动前自动恢复历史会话。',
|
||||
'agents.sessions.settingLoading': '读取中',
|
||||
'agents.sessions.settingOn': '已开启',
|
||||
'agents.sessions.settingOff': '已关闭',
|
||||
'agents.sessions.settingLoadFailed': '读取自动恢复设置失败:{error}',
|
||||
'agents.sessions.applyManagedFirst': '请先应用 Codex 的 CPA 托管配置',
|
||||
'agents.sessions.repairNow': '立即恢复',
|
||||
'agents.sessions.repairing': '正在恢复',
|
||||
'agents.sessions.loadFailed': '读取 Codex 会话失败:{error}',
|
||||
'agents.sessions.localSessions': '本地会话',
|
||||
'agents.sessions.pageDescription': '第 {page} 页 · 每页最多 {size} 条',
|
||||
'agents.sessions.selected': '已选 {count} 条',
|
||||
'agents.sessions.selectAll': '全选本页',
|
||||
'agents.sessions.clearSelection': '清空选择',
|
||||
'agents.sessions.deleteSelected': '删除所选',
|
||||
'agents.sessions.multiSelect': '多选',
|
||||
'agents.sessions.loading': '正在扫描本地会话…',
|
||||
'agents.sessions.empty': '没有找到可显示的 Codex 会话。',
|
||||
'agents.sessions.selectSession': '选择会话 {title}',
|
||||
'agents.sessions.untitled': '未命名会话',
|
||||
'agents.sessions.noProject': '无项目路径',
|
||||
'agents.sessions.noProvider': '未知 Provider',
|
||||
'agents.sessions.noTime': '更新时间未知',
|
||||
'agents.sessions.activeBadge': '未归档',
|
||||
'agents.sessions.archivedBadge': '已归档',
|
||||
'agents.sessions.previousPage': '上一页',
|
||||
'agents.sessions.nextPage': '下一页',
|
||||
'agents.sessions.pageNumber': '第 {page} 页',
|
||||
'agents.sessions.deleteOneTitle': '删除这个会话?',
|
||||
'agents.sessions.deleteManyTitle': '删除 {count} 个会话?',
|
||||
'agents.sessions.deleteOneDescription': '将先创建备份,再删除“{title}”及其已知关联记录。此操作不提供界面内撤销。',
|
||||
'agents.sessions.deleteManyDescription': '将先创建备份,再删除以下 {count} 个会话及其已知关联记录:{preview}{extra}',
|
||||
'agents.sessions.deleteMore': ',另有 {count} 个',
|
||||
'agents.sessions.confirmDelete': '备份并删除',
|
||||
'agents.sessions.deleteResult': '已删除 {deleted} 个,失败 {failed} 个。{details}{backup}',
|
||||
'agents.sessions.deleteFailed': '删除会话失败:{error}',
|
||||
'agents.sessions.backupAt': '备份位置:{path}',
|
||||
'agents.sessions.settingFailed': '保存自动恢复设置失败:{error}',
|
||||
'agents.sessions.repairResult': '历史会话已恢复到 {provider}:迁移 {files} 个 rollout 文件、更新 {rows} 条数据库记录、跳过 {skipped} 个占用文件。',
|
||||
'agents.sessions.repairFailed': '恢复历史会话失败:{error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': '正在扫描会话',
|
||||
'agents.sessions.progress.backingUp': '正在创建备份',
|
||||
'agents.sessions.progress.rewriting': '正在迁移历史会话',
|
||||
'agents.sessions.progress.updatingDatabase': '正在同步数据库',
|
||||
'agents.sessions.progress.complete': '恢复完成',
|
||||
'agents.sessions.scanIndex': '检查无效会话记录',
|
||||
'agents.sessions.scanningIndex': '正在检查无效记录',
|
||||
'agents.sessions.cleanupNoCandidates': '未发现可安全清理的无效会话记录。',
|
||||
'agents.sessions.cleanupPreviewFailed': '检查无效会话记录失败:{error}',
|
||||
'agents.sessions.cleanupTitle': '可清理的无效索引',
|
||||
'agents.sessions.cleanupDescription': '发现 {count} 条只存在于 session_index.jsonl、但已没有真实会话来源的记录。默认不选择;确认 Codex 和 ChatGPT 已退出后,再逐项清理。未知格式记录会保留。',
|
||||
'agents.sessions.cleanupSelectAll': '选择全部候选',
|
||||
'agents.sessions.cleanupConfirm': '清理所选 {count} 条',
|
||||
'agents.sessions.cleanupResult': '已清理 {count} 条无效会话索引。{backup}',
|
||||
'agents.sessions.cleanupFailed': '清理无效会话索引失败:{error}',
|
||||
'agents.localClients': '本机客户端',
|
||||
'agents.selectClient': '选择需要管理的智能体',
|
||||
'agents.installStatus': '安装状态',
|
||||
@@ -1092,7 +1159,74 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.tabs.core': 'Core Configuration',
|
||||
'agents.tabs.sessions': 'Session Management',
|
||||
'agents.sessions.title': 'Codex Session Management',
|
||||
'agents.sessions.description': 'Session browsing and management will appear here.',
|
||||
'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.',
|
||||
'agents.sessions.refresh': 'Refresh',
|
||||
'agents.sessions.currentPage': 'Current page',
|
||||
'agents.sessions.active': 'Active',
|
||||
'agents.sessions.archived': 'Archived',
|
||||
'agents.sessions.database': 'Database source',
|
||||
'agents.sessions.databaseMissing': 'No session database found',
|
||||
'agents.sessions.repairTitle': 'Restore Historical Sessions',
|
||||
'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.',
|
||||
'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex',
|
||||
'agents.sessions.autoRestoreDescription': 'Automatically sync sessions when Codex is launched from this app.',
|
||||
'agents.sessions.autoRepairEnabled': 'Automatic restoration before launch is enabled.',
|
||||
'agents.sessions.autoRepairDisabled': 'Automatic restoration before launch is disabled.',
|
||||
'agents.sessions.settingLoading': 'Loading',
|
||||
'agents.sessions.settingOn': 'On',
|
||||
'agents.sessions.settingOff': 'Off',
|
||||
'agents.sessions.settingLoadFailed': 'Failed to load the automatic-restoration setting: {error}',
|
||||
'agents.sessions.applyManagedFirst': 'Apply the managed CPA configuration for Codex first',
|
||||
'agents.sessions.repairNow': 'Restore now',
|
||||
'agents.sessions.repairing': 'Restoring',
|
||||
'agents.sessions.loadFailed': 'Failed to load Codex sessions: {error}',
|
||||
'agents.sessions.localSessions': 'Local sessions',
|
||||
'agents.sessions.pageDescription': 'Page {page} · up to {size} items per page',
|
||||
'agents.sessions.selected': '{count} selected',
|
||||
'agents.sessions.selectAll': 'Select this page',
|
||||
'agents.sessions.clearSelection': 'Clear selection',
|
||||
'agents.sessions.deleteSelected': 'Delete selected',
|
||||
'agents.sessions.multiSelect': 'Select multiple',
|
||||
'agents.sessions.loading': 'Scanning local sessions…',
|
||||
'agents.sessions.empty': 'No Codex sessions were found.',
|
||||
'agents.sessions.selectSession': 'Select session {title}',
|
||||
'agents.sessions.untitled': 'Untitled session',
|
||||
'agents.sessions.noProject': 'No project path',
|
||||
'agents.sessions.noProvider': 'Unknown provider',
|
||||
'agents.sessions.noTime': 'Unknown update time',
|
||||
'agents.sessions.activeBadge': 'Active',
|
||||
'agents.sessions.archivedBadge': 'Archived',
|
||||
'agents.sessions.previousPage': 'Previous page',
|
||||
'agents.sessions.nextPage': 'Next page',
|
||||
'agents.sessions.pageNumber': 'Page {page}',
|
||||
'agents.sessions.deleteOneTitle': 'Delete this session?',
|
||||
'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.deleteMore': ', plus {count} more',
|
||||
'agents.sessions.confirmDelete': 'Back up and delete',
|
||||
'agents.sessions.deleteResult': 'Deleted {deleted}; failed {failed}. {details}{backup}',
|
||||
'agents.sessions.deleteFailed': 'Failed to delete sessions: {error}',
|
||||
'agents.sessions.backupAt': 'Backup: {path}',
|
||||
'agents.sessions.settingFailed': 'Failed to save the automatic-restoration setting: {error}',
|
||||
'agents.sessions.repairResult': 'Historical sessions restored to {provider}: migrated {files} rollout files, updated {rows} database rows, and skipped {skipped} locked files.',
|
||||
'agents.sessions.repairFailed': 'Failed to restore historical sessions: {error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': 'Scanning sessions',
|
||||
'agents.sessions.progress.backingUp': 'Creating backup',
|
||||
'agents.sessions.progress.rewriting': 'Migrating historical sessions',
|
||||
'agents.sessions.progress.updatingDatabase': 'Synchronizing databases',
|
||||
'agents.sessions.progress.complete': 'Restoration complete',
|
||||
'agents.sessions.scanIndex': 'Check invalid session records',
|
||||
'agents.sessions.scanningIndex': 'Checking invalid records',
|
||||
'agents.sessions.cleanupNoCandidates': 'No safely removable invalid session records were found.',
|
||||
'agents.sessions.cleanupPreviewFailed': 'Failed to check invalid session records: {error}',
|
||||
'agents.sessions.cleanupTitle': 'Removable invalid index entries',
|
||||
'agents.sessions.cleanupDescription': 'Found {count} records that exist only in session_index.jsonl and no longer have a real session source. Nothing is selected by default. Exit Codex and ChatGPT, then confirm each item to remove. Unknown record formats are preserved.',
|
||||
'agents.sessions.cleanupSelectAll': 'Select all candidates',
|
||||
'agents.sessions.cleanupConfirm': 'Remove {count} selected',
|
||||
'agents.sessions.cleanupResult': 'Removed {count} invalid session index entries. {backup}',
|
||||
'agents.sessions.cleanupFailed': 'Failed to remove invalid session index entries: {error}',
|
||||
'agents.localClients': 'Local Clients',
|
||||
'agents.selectClient': 'Select an agent to manage',
|
||||
'agents.installStatus': 'Installation Status',
|
||||
|
||||
+18
-21
@@ -17,7 +17,6 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -37,6 +36,8 @@ import {
|
||||
} from '../services/agentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import { CodexSessionAutoRestoreCard } from './CodexSessionAutoRestoreCard';
|
||||
import { CodexSessionsPanel } from './CodexSessionsPanel';
|
||||
|
||||
type AgentClientId =
|
||||
| 'claude-code'
|
||||
@@ -828,7 +829,7 @@ export function AgentsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="agent-model-section">
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong><span>{t('agents.useModelDescription')}</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
@@ -859,16 +860,18 @@ export function AgentsPage() {
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className={`agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
|
||||
<div className="agent-modification-copy">
|
||||
<strong>{t('agents.modify.title')}</strong>
|
||||
<span>{activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.modify.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.modify.applied')
|
||||
: t('agents.modify.unconfigured')}</span>
|
||||
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
|
||||
<div className="agent-section-heading">
|
||||
<div>
|
||||
<strong>{t('agents.modify.title')}</strong>
|
||||
<span>{activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.modify.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.modify.applied')
|
||||
: t('agents.modify.unconfigured')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-modification-control">
|
||||
<div className="agent-modification-buttons">
|
||||
@@ -902,6 +905,8 @@ export function AgentsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected === 'codex' ? <CodexSessionAutoRestoreCard /> : null}
|
||||
|
||||
<div className="agent-config-footer">
|
||||
<div className="agent-config-summary">
|
||||
{activeStatus?.modificationState === 'applied' ? <Check size={16} /> : <Sparkles size={16} />}
|
||||
@@ -978,15 +983,7 @@ export function AgentsPage() {
|
||||
role="tabpanel"
|
||||
aria-labelledby="agent-subpage-tab-sessions"
|
||||
>
|
||||
<div className="agent-sessions-empty">
|
||||
<span className="agent-sessions-icon" aria-hidden="true">
|
||||
<MessageSquare size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{t('agents.sessions.title')}</h3>
|
||||
<p>{t('agents.sessions.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CodexSessionsPanel managedProviderActive={activeStatus?.modificationState === 'applied'} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type RepairOnLaunchSetting = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type StatusMessage = {
|
||||
kind: 'success' | 'error';
|
||||
text: string;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function CodexSessionAutoRestoreCard() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void invoke<RepairOnLaunchSetting>('get_codex_session_repair_on_launch')
|
||||
.then((result) => {
|
||||
if (!disposed) setEnabled(result.enabled);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
text: t('agents.sessions.settingLoadFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const updateSetting = async (nextEnabled: boolean) => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const result = await invoke<RepairOnLaunchSetting>('set_codex_session_repair_on_launch', {
|
||||
request: { enabled: nextEnabled },
|
||||
});
|
||||
setEnabled(result.enabled);
|
||||
setStatus({
|
||||
kind: 'success',
|
||||
text: result.enabled
|
||||
? t('agents.sessions.autoRepairEnabled')
|
||||
: t('agents.sessions.autoRepairDisabled'),
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
text: t('agents.sessions.settingFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`agent-core-setting-section codex-auto-restore-card ${enabled ? 'enabled' : ''}`}
|
||||
aria-busy={loading || saving}
|
||||
>
|
||||
<div className="agent-section-heading">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.autoRepair')}</strong>
|
||||
<span>{t('agents.sessions.autoRestoreDescription')}</span>
|
||||
</div>
|
||||
<label className="codex-auto-restore-toggle">
|
||||
{loading || saving ? <LoaderCircle size={14} className="spin" aria-hidden="true" /> : null}
|
||||
<strong>
|
||||
{loading
|
||||
? t('agents.sessions.settingLoading')
|
||||
: enabled
|
||||
? t('agents.sessions.settingOn')
|
||||
: t('agents.sessions.settingOff')}
|
||||
</strong>
|
||||
<span className="switch-control codex-auto-restore-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={t('agents.sessions.autoRepair')}
|
||||
checked={enabled}
|
||||
disabled={loading || saving}
|
||||
onChange={(event) => void updateSetting(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{status ? (
|
||||
<small
|
||||
className={`codex-auto-restore-message ${status.kind}`}
|
||||
role={status.kind === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{status.text}
|
||||
</small>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Archive,
|
||||
Database,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import {
|
||||
codexSessionPageCounts,
|
||||
retainVisibleCodexSessionIds,
|
||||
type CodexSessionDeleteBatchResult,
|
||||
type CodexSessionPage,
|
||||
type CodexSessionRepairProgress,
|
||||
type CodexSessionRepairResult,
|
||||
type CodexSessionSummary,
|
||||
type SessionIndexCleanupPreview,
|
||||
type SessionIndexCleanupResult,
|
||||
} from '../services/codexSessionState';
|
||||
|
||||
type Notice = {
|
||||
kind: 'success' | 'warning' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type DeleteConfirmation = {
|
||||
ids: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function CodexSessionsPanel({ managedProviderActive }: { managedProviderActive: boolean }) {
|
||||
const { formatDate, t } = useI18n();
|
||||
const [page, setPage] = useState<CodexSessionPage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [operation, setOperation] = useState<'delete' | 'repair' | 'preview' | 'cleanup' | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation | null>(null);
|
||||
const [cleanupPreview, setCleanupPreview] = useState<SessionIndexCleanupPreview | null>(null);
|
||||
const [cleanupSelectedIds, setCleanupSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [repairProgress, setRepairProgress] = useState<CodexSessionRepairProgress>({
|
||||
phase: 'scanning',
|
||||
percent: 0,
|
||||
processed: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const loadPage = useCallback(async (offset = 0, silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const result = await invoke<CodexSessionPage>('list_codex_sessions', {
|
||||
request: { offset, limit: PAGE_SIZE },
|
||||
});
|
||||
if (result.sessions.length === 0 && result.offset > 0) {
|
||||
return await loadPage(Math.max(0, result.offset - result.limit), silent);
|
||||
}
|
||||
setPage(result);
|
||||
setSelectedIds((current) => retainVisibleCodexSessionIds(current, result.sessions));
|
||||
if (!silent && result.warnings.length > 0) {
|
||||
setNotice({ kind: 'warning', message: result.warnings.join(';') });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.loadFailed', { error: errorMessage(error) }) });
|
||||
return null;
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let stop: (() => void) | undefined;
|
||||
void listen<CodexSessionRepairProgress>('codex-session-repair-progress', (event) => {
|
||||
if (!disposed) setRepairProgress(event.payload);
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
else stop = unlisten;
|
||||
}).catch(() => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
stop?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessions = page?.sessions ?? [];
|
||||
const pageCounts = codexSessionPageCounts(sessions);
|
||||
const currentPage = Math.floor((page?.offset ?? 0) / (page?.limit ?? PAGE_SIZE)) + 1;
|
||||
const selectedSessions = useMemo(
|
||||
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sessions],
|
||||
);
|
||||
const allSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
||||
const busy = operation !== null;
|
||||
|
||||
const toggleSelected = (id: string, selected: boolean) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (selected) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const requestDelete = (targets: CodexSessionSummary[]) => {
|
||||
if (targets.length === 0) return;
|
||||
const preview = targets
|
||||
.slice(0, 6)
|
||||
.map((session) => session.title || session.id)
|
||||
.join('、');
|
||||
const extra = Math.max(0, targets.length - 6);
|
||||
setDeleteConfirmation({
|
||||
ids: targets.map((session) => session.id),
|
||||
title: targets.length === 1
|
||||
? t('agents.sessions.deleteOneTitle')
|
||||
: t('agents.sessions.deleteManyTitle', { count: targets.length }),
|
||||
description: targets.length === 1
|
||||
? t('agents.sessions.deleteOneDescription', { title: preview })
|
||||
: t('agents.sessions.deleteManyDescription', {
|
||||
count: targets.length,
|
||||
preview,
|
||||
extra: extra > 0 ? t('agents.sessions.deleteMore', { count: extra }) : '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteConfirmation) return;
|
||||
const request = deleteConfirmation;
|
||||
setDeleteConfirmation(null);
|
||||
setOperation('delete');
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await invoke<CodexSessionDeleteBatchResult>('delete_codex_sessions', {
|
||||
request: { sessionIds: request.ids },
|
||||
});
|
||||
const failed = result.results.filter((item) => !['deleted', 'partial'].includes(item.status));
|
||||
const partial = result.results.filter((item) => item.status === 'partial');
|
||||
const backupPaths = result.results
|
||||
.map((item) => item.backupPath)
|
||||
.filter((path): path is string => Boolean(path));
|
||||
const details = [...partial, ...failed].map((item) => `${item.sessionId}: ${item.message}`).join(';');
|
||||
setNotice({
|
||||
kind: failed.length > 0 ? (result.deletedCount > 0 ? 'warning' : 'error') : partial.length > 0 ? 'warning' : 'success',
|
||||
message: t('agents.sessions.deleteResult', {
|
||||
deleted: result.deletedCount,
|
||||
failed: result.failedCount,
|
||||
details: details ? ` ${details}` : '',
|
||||
backup: backupPaths[0] ? t('agents.sessions.backupAt', { path: backupPaths[0] }) : '',
|
||||
}),
|
||||
});
|
||||
setSelectedIds(new Set());
|
||||
await loadPage(page?.offset ?? 0, true);
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.deleteFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const repairSessions = async () => {
|
||||
setOperation('repair');
|
||||
setNotice(null);
|
||||
setRepairProgress({ phase: 'scanning', percent: 4, processed: 0, total: 0 });
|
||||
try {
|
||||
const result = await invoke<CodexSessionRepairResult>('repair_codex_session_metadata');
|
||||
const messages = [
|
||||
t('agents.sessions.repairResult', {
|
||||
provider: result.targetProvider,
|
||||
files: result.changedRolloutFiles,
|
||||
rows: result.sqliteRowsUpdated,
|
||||
skipped: result.skippedLockedFiles.length,
|
||||
}),
|
||||
];
|
||||
if (result.encryptedContentWarning) messages.push(result.encryptedContentWarning);
|
||||
if (result.warnings.length > 0) messages.push(result.warnings.join(';'));
|
||||
if (result.backupPath) messages.push(t('agents.sessions.backupAt', { path: result.backupPath }));
|
||||
setNotice({
|
||||
kind: result.encryptedContentWarning || result.warnings.length > 0 ? 'warning' : 'success',
|
||||
message: messages.join(' '),
|
||||
});
|
||||
await loadPage(page?.offset ?? 0, true);
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.repairFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const previewCleanup = async () => {
|
||||
setOperation('preview');
|
||||
setNotice(null);
|
||||
try {
|
||||
const preview = await invoke<SessionIndexCleanupPreview>('preview_codex_session_index_cleanup');
|
||||
setCleanupSelectedIds(new Set());
|
||||
if (preview.candidates.length === 0) {
|
||||
setCleanupPreview(null);
|
||||
setNotice({ kind: 'success', message: t('agents.sessions.cleanupNoCandidates') });
|
||||
} else {
|
||||
setCleanupPreview(preview);
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
message: t('agents.sessions.cleanupPreviewFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCleanup = async () => {
|
||||
if (!cleanupPreview || cleanupSelectedIds.size === 0) return;
|
||||
setOperation('cleanup');
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await invoke<SessionIndexCleanupResult>('apply_codex_session_index_cleanup', {
|
||||
request: {
|
||||
snapshotSha256: cleanupPreview.snapshotSha256,
|
||||
threadIds: Array.from(cleanupSelectedIds),
|
||||
},
|
||||
});
|
||||
setCleanupPreview(null);
|
||||
setCleanupSelectedIds(new Set());
|
||||
setNotice({
|
||||
kind: 'success',
|
||||
message: t('agents.sessions.cleanupResult', {
|
||||
count: result.prunedEntries,
|
||||
backup: result.backupPath ? t('agents.sessions.backupAt', { path: result.backupPath }) : '',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
setCleanupPreview(null);
|
||||
setCleanupSelectedIds(new Set());
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.cleanupFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const repairPhaseText = t(`agents.sessions.progress.${repairProgress.phase}` as
|
||||
| 'agents.sessions.progress.scanning'
|
||||
| 'agents.sessions.progress.backingUp'
|
||||
| 'agents.sessions.progress.rewriting'
|
||||
| 'agents.sessions.progress.updatingDatabase'
|
||||
| 'agents.sessions.progress.complete');
|
||||
|
||||
return (
|
||||
<div className="codex-sessions-page">
|
||||
<section className="codex-session-overview">
|
||||
<div className="codex-session-overview-head">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.title')}</strong>
|
||||
<span>{t('agents.sessions.description')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void loadPage(page?.offset ?? 0)}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'spin' : ''} />
|
||||
{t('agents.sessions.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="codex-session-metrics">
|
||||
<div><span>{t('agents.sessions.currentPage')}</span><strong>{sessions.length}</strong></div>
|
||||
<div><span>{t('agents.sessions.active')}</span><strong>{pageCounts.active}</strong></div>
|
||||
<div><span>{t('agents.sessions.archived')}</span><strong>{pageCounts.archived}</strong></div>
|
||||
<div
|
||||
className="codex-session-database-metric"
|
||||
title={page?.databasePaths.join('\n') || page?.codexHome}
|
||||
>
|
||||
<span><Database size={13} />{t('agents.sessions.database')}</span>
|
||||
<strong>{page?.databasePaths[0] || t('agents.sessions.databaseMissing')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="codex-session-repair-panel">
|
||||
<div className="codex-session-repair-copy">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>{t('agents.sessions.repairTitle')}</strong>
|
||||
<span>{t('agents.sessions.repairDescription')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="codex-session-repair-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void previewCleanup()}
|
||||
>
|
||||
{operation === 'preview' ? <LoaderCircle size={15} className="spin" /> : <ScanSearch size={15} />}
|
||||
{operation === 'preview' ? t('agents.sessions.scanningIndex') : t('agents.sessions.scanIndex')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || !managedProviderActive}
|
||||
title={!managedProviderActive ? t('agents.sessions.applyManagedFirst') : undefined}
|
||||
onClick={() => void repairSessions()}
|
||||
>
|
||||
{operation === 'repair' ? <LoaderCircle size={15} className="spin" /> : <ShieldCheck size={15} />}
|
||||
{operation === 'repair' ? t('agents.sessions.repairing') : t('agents.sessions.repairNow')}
|
||||
</button>
|
||||
</div>
|
||||
{operation === 'repair' ? (
|
||||
<div className="codex-session-repair-progress">
|
||||
<div>
|
||||
<span>
|
||||
{repairPhaseText}
|
||||
{repairProgress.total > 0
|
||||
? ` · ${t('agents.sessions.progressCount', { processed: repairProgress.processed, total: repairProgress.total })}`
|
||||
: ''}
|
||||
</span>
|
||||
<strong>{repairProgress.percent}%</strong>
|
||||
</div>
|
||||
<div
|
||||
className="codex-session-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={repairProgress.percent}
|
||||
>
|
||||
<i style={{ width: `${repairProgress.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div className={`codex-session-notice ${notice.kind}`} role={notice.kind === 'error' ? 'alert' : 'status'}>
|
||||
{notice.kind === 'error' ? <TriangleAlert size={16} /> : notice.kind === 'warning' ? <Info size={16} /> : <ShieldCheck size={16} />}
|
||||
<span>{notice.message}</span>
|
||||
<button type="button" aria-label={t('common.close')} onClick={() => setNotice(null)}><X size={14} /></button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="codex-session-list-section">
|
||||
<div className="codex-session-list-head">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.localSessions')}</strong>
|
||||
<span>{t('agents.sessions.pageDescription', { page: currentPage, size: page?.limit ?? PAGE_SIZE })}</span>
|
||||
</div>
|
||||
<div className="codex-session-selection-actions">
|
||||
{selectionMode ? <span>{t('agents.sessions.selected', { count: selectedSessions.length })}</span> : null}
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || allSelected} onClick={() => setSelectedIds(new Set(sessions.map((session) => session.id)))}>{t('agents.sessions.selectAll')}</button>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || selectedIds.size === 0} onClick={() => setSelectedIds(new Set())}>{t('agents.sessions.clearSelection')}</button>
|
||||
<button type="button" className="danger-button compact-button" disabled={busy || selectedSessions.length === 0} onClick={() => requestDelete(selectedSessions)}><Trash2 size={14} />{t('agents.sessions.deleteSelected')}</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || sessions.length === 0}
|
||||
onClick={() => {
|
||||
setSelectionMode((current) => !current);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
{selectionMode ? t('common.cancel') : t('agents.sessions.multiSelect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="codex-session-empty"><LoaderCircle size={22} className="spin" /><span>{t('agents.sessions.loading')}</span></div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="codex-session-empty"><Archive size={22} /><span>{t('agents.sessions.empty')}</span></div>
|
||||
) : (
|
||||
<div className="codex-session-list">
|
||||
{sessions.map((session) => (
|
||||
<article className={`codex-session-row ${selectedIds.has(session.id) ? 'selected' : ''}`} key={session.id}>
|
||||
{selectionMode ? (
|
||||
<label className="codex-session-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(session.id)}
|
||||
aria-label={t('agents.sessions.selectSession', { title: session.title || session.id })}
|
||||
onChange={(event) => toggleSelected(session.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="codex-session-main">
|
||||
<strong>{session.title || t('agents.sessions.untitled')}</strong>
|
||||
<code>{session.id}</code>
|
||||
<span title={session.cwd}>{session.cwd || t('agents.sessions.noProject')}</span>
|
||||
</div>
|
||||
<div className="codex-session-meta">
|
||||
<span className={session.archived ? 'archived' : 'active'}>{session.archived ? t('agents.sessions.archivedBadge') : t('agents.sessions.activeBadge')}</span>
|
||||
<small>{session.modelProvider || t('agents.sessions.noProvider')}</small>
|
||||
<time>{session.updatedAtMs ? formatDate(session.updatedAtMs, { dateStyle: 'medium', timeStyle: 'short' }) : t('agents.sessions.noTime')}</time>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button compact-button codex-session-delete-button"
|
||||
disabled={busy}
|
||||
onClick={() => requestDelete([session])}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="codex-session-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
aria-label={t('agents.sessions.previousPage')}
|
||||
title={t('agents.sessions.previousPage')}
|
||||
disabled={busy || loading || !page || page.offset === 0}
|
||||
onClick={() => void loadPage(Math.max(0, (page?.offset ?? 0) - (page?.limit ?? PAGE_SIZE)))}
|
||||
><ArrowLeft size={15} /></button>
|
||||
<span>{t('agents.sessions.pageNumber', { page: currentPage })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
aria-label={t('agents.sessions.nextPage')}
|
||||
title={t('agents.sessions.nextPage')}
|
||||
disabled={busy || loading || !page?.hasMore}
|
||||
onClick={() => void loadPage((page?.offset ?? 0) + (page?.limit ?? PAGE_SIZE))}
|
||||
><ArrowRight size={15} /></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{deleteConfirmation ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog codex-session-dialog" role="alertdialog" aria-modal="true" aria-labelledby="codex-session-delete-title">
|
||||
<div className="config-dialog-heading"><div><TriangleAlert size={19} /><h2 id="codex-session-delete-title">{deleteConfirmation.title}</h2></div></div>
|
||||
<p>{deleteConfirmation.description}</p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cleanupPreview ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog codex-session-cleanup-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-session-cleanup-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><TriangleAlert size={19} /><h2 id="codex-session-cleanup-title">{t('agents.sessions.cleanupTitle')}</h2></div>
|
||||
<button type="button" className="codex-session-dialog-close" aria-label={t('common.close')} onClick={() => setCleanupPreview(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<p>{t('agents.sessions.cleanupDescription', { count: cleanupPreview.candidates.length })}</p>
|
||||
<label className="codex-session-cleanup-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSelectedIds.size === cleanupPreview.candidates.length && cleanupPreview.candidates.length > 0}
|
||||
onChange={(event) => setCleanupSelectedIds(event.currentTarget.checked ? new Set(cleanupPreview.candidates.map((candidate) => candidate.id)) : new Set())}
|
||||
/>
|
||||
<span>{t('agents.sessions.cleanupSelectAll')}</span>
|
||||
</label>
|
||||
<div className="codex-session-cleanup-list">
|
||||
{cleanupPreview.candidates.map((candidate) => (
|
||||
<label key={candidate.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSelectedIds.has(candidate.id)}
|
||||
onChange={(event) => {
|
||||
setCleanupSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.currentTarget.checked) next.add(candidate.id);
|
||||
else next.delete(candidate.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span><strong>{candidate.threadName || t('agents.sessions.untitled')}</strong><code>{candidate.id}</code><small>{candidate.updatedAt}</small></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" disabled={operation === 'cleanup'} onClick={() => setCleanupPreview(null)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" disabled={operation === 'cleanup' || cleanupSelectedIds.size === 0} onClick={() => void applyCleanup()}>
|
||||
{operation === 'cleanup' ? <LoaderCircle size={15} className="spin" /> : <Trash2 size={15} />}
|
||||
{t('agents.sessions.cleanupConfirm', { count: cleanupSelectedIds.size })}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type CodexSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
modelProvider: string;
|
||||
archived: boolean;
|
||||
updatedAtMs: number | null;
|
||||
databasePath: string;
|
||||
};
|
||||
|
||||
export type CodexSessionPage = {
|
||||
codexHome: string;
|
||||
databasePaths: string[];
|
||||
sessions: CodexSessionSummary[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
repairOnLaunch: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type CodexSessionDeleteResult = {
|
||||
sessionId: string;
|
||||
status: 'deleted' | 'partial' | 'notFound' | 'failed';
|
||||
message: string;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
export type CodexSessionDeleteBatchResult = {
|
||||
results: CodexSessionDeleteResult[];
|
||||
deletedCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
|
||||
export type CodexSessionRepairProgress = {
|
||||
phase: 'scanning' | 'backingUp' | 'rewriting' | 'updatingDatabase' | 'complete';
|
||||
percent: number;
|
||||
processed: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CodexSessionRepairResult = {
|
||||
targetProvider: string;
|
||||
changedRolloutFiles: number;
|
||||
sqliteRowsUpdated: number;
|
||||
skippedLockedFiles: string[];
|
||||
backupPath: string | null;
|
||||
encryptedContentWarning: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupCandidate = {
|
||||
id: string;
|
||||
threadName: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupPreview = {
|
||||
snapshotSha256: string;
|
||||
candidates: SessionIndexCleanupCandidate[];
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupResult = {
|
||||
prunedEntries: number;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
export type CodexSessionPageCounts = {
|
||||
active: number;
|
||||
archived: number;
|
||||
};
|
||||
|
||||
export function codexSessionPageCounts(
|
||||
sessions: readonly Pick<CodexSessionSummary, 'archived'>[],
|
||||
): CodexSessionPageCounts {
|
||||
const archived = sessions.reduce(
|
||||
(count, session) => count + Number(session.archived),
|
||||
0,
|
||||
);
|
||||
return { active: sessions.length - archived, archived };
|
||||
}
|
||||
|
||||
export function retainVisibleCodexSessionIds(
|
||||
selectedIds: ReadonlySet<string>,
|
||||
sessions: readonly Pick<CodexSessionSummary, 'id'>[],
|
||||
): Set<string> {
|
||||
const visibleIds = new Set(sessions.map((session) => session.id));
|
||||
return new Set(Array.from(selectedIds).filter((id) => visibleIds.has(id)));
|
||||
}
|
||||
+668
-50
@@ -3926,43 +3926,507 @@ input:focus-visible {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 360px;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
border: 1px dashed #d5d2cb;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.codex-sessions-page {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.codex-session-overview,
|
||||
.codex-session-list-section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 8px;
|
||||
background: #faf9f5;
|
||||
}
|
||||
|
||||
.agent-sessions-empty {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
.codex-session-overview-head,
|
||||
.codex-session-list-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: min(100%, 420px);
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agent-sessions-icon {
|
||||
.codex-session-overview-head > div,
|
||||
.codex-session-list-head > div:first-child {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.codex-session-overview-head strong,
|
||||
.codex-session-list-head strong {
|
||||
color: #2d2a26;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.codex-session-overview-head span,
|
||||
.codex-session-list-head > div:first-child > span {
|
||||
color: #8a847c;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.codex-session-overview-head .compact-button,
|
||||
.codex-session-selection-actions .compact-button,
|
||||
.codex-session-repair-actions .compact-button,
|
||||
.codex-session-delete-button {
|
||||
min-height: 32px;
|
||||
margin-top: 0;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.codex-session-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(74px, 0.55fr)) minmax(150px, 2fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.codex-session-metrics > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 9px;
|
||||
border-radius: 7px;
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.codex-session-metrics span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.codex-session-metrics strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #2d2a26;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-session-database-metric strong {
|
||||
direction: rtl;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codex-session-repair-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 8px;
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.codex-session-repair-copy,
|
||||
.codex-session-repair-actions,
|
||||
.codex-session-selection-actions,
|
||||
.codex-session-notice,
|
||||
.codex-session-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.codex-session-repair-copy {
|
||||
min-width: 0;
|
||||
gap: 9px;
|
||||
color: #5f5a53;
|
||||
}
|
||||
|
||||
.agent-sessions-empty h3 {
|
||||
margin: 0 0 5px;
|
||||
color: #2d2a26;
|
||||
font-size: 14px;
|
||||
.codex-session-repair-copy > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.agent-sessions-empty p {
|
||||
margin: 0;
|
||||
.codex-session-repair-copy strong {
|
||||
color: #2d2a26;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.codex-session-repair-copy span {
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.codex-session-repair-actions {
|
||||
flex: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.codex-session-repair-progress {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.codex-session-repair-progress > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #7a746c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.codex-session-progress-track {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #dce8df;
|
||||
}
|
||||
|
||||
.codex-session-progress-track i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #4f825c;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.codex-session-notice {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid #b6d3bd;
|
||||
border-radius: 7px;
|
||||
background: #eef7f0;
|
||||
color: #2f6b3f;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.codex-session-notice.warning {
|
||||
border-color: #dfbf91;
|
||||
background: #fff7e9;
|
||||
color: #8b5b21;
|
||||
}
|
||||
|
||||
.codex-session-notice.error {
|
||||
border-color: #dfb7b0;
|
||||
background: #fff1ee;
|
||||
color: #9f2d20;
|
||||
}
|
||||
|
||||
.codex-session-notice > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.codex-session-notice > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.codex-session-notice > button,
|
||||
.codex-session-dialog-close {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.codex-session-notice > button:hover,
|
||||
.codex-session-dialog-close:hover {
|
||||
background: rgba(45, 42, 38, 0.08);
|
||||
}
|
||||
|
||||
.codex-session-list-section {
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.codex-session-selection-actions {
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.codex-session-selection-actions > span {
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-session-list {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.codex-session-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(150px, auto) auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 76px;
|
||||
padding: 10px 11px;
|
||||
border-bottom: 1px solid #eeeae2;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.codex-session-row:has(.codex-session-checkbox) {
|
||||
grid-template-columns: 20px minmax(0, 1fr) minmax(150px, auto) auto;
|
||||
}
|
||||
|
||||
.codex-session-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.codex-session-row:hover,
|
||||
.codex-session-row.selected {
|
||||
background: #faf9f5;
|
||||
}
|
||||
|
||||
.codex-session-row.selected {
|
||||
box-shadow: inset 3px 0 #4f825c;
|
||||
}
|
||||
|
||||
.codex-session-checkbox {
|
||||
display: grid;
|
||||
align-self: stretch;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.codex-session-checkbox input,
|
||||
.codex-session-cleanup-dialog input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
accent-color: #4f825c;
|
||||
}
|
||||
|
||||
.codex-session-main,
|
||||
.codex-session-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.codex-session-main {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.codex-session-main strong,
|
||||
.codex-session-main code,
|
||||
.codex-session-main span,
|
||||
.codex-session-meta small,
|
||||
.codex-session-meta time {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-session-main strong {
|
||||
color: #2d2a26;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.codex-session-main code {
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.codex-session-main span {
|
||||
color: #5f5a53;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.codex-session-meta {
|
||||
justify-items: end;
|
||||
gap: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.codex-session-meta > span {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid #b6d3bd;
|
||||
border-radius: 999px;
|
||||
background: #eef7f0;
|
||||
color: #2f6b3f;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.codex-session-meta > span.archived {
|
||||
border-color: #d5d2cb;
|
||||
background: #f4f1ea;
|
||||
color: #5f5a53;
|
||||
}
|
||||
|
||||
.codex-session-meta small,
|
||||
.codex-session-meta time {
|
||||
max-width: 190px;
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.codex-session-delete-button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.codex-session-empty {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 9px;
|
||||
padding: 18px;
|
||||
border: 1px dashed #d5d2cb;
|
||||
border-radius: 7px;
|
||||
background: #faf9f5;
|
||||
color: #8a847c;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codex-session-pagination {
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.codex-session-pagination button {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.codex-session-pagination span {
|
||||
min-width: 72px;
|
||||
color: #5f5a53;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codex-session-dialog,
|
||||
.codex-session-cleanup-dialog {
|
||||
grid-template-rows: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.codex-session-dialog {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.codex-session-dialog .config-dialog-heading > div,
|
||||
.codex-session-cleanup-dialog .config-dialog-heading > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #9f2d20;
|
||||
}
|
||||
|
||||
.codex-session-dialog > p,
|
||||
.codex-session-cleanup-dialog > p {
|
||||
margin: 0;
|
||||
color: #5f5a53;
|
||||
font-size: 11px;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-dialog {
|
||||
width: min(580px, 100%);
|
||||
max-height: min(720px, calc(100vh - 40px));
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #5f5a53;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list {
|
||||
display: grid;
|
||||
min-height: 120px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list > label {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 62px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eeeae2;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list > label:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list > label:hover {
|
||||
background: #faf9f5;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list > label > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list strong,
|
||||
.codex-session-cleanup-list code,
|
||||
.codex-session-cleanup-list small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list strong {
|
||||
color: #2d2a26;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-list code,
|
||||
.codex-session-cleanup-list small {
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.agent-status-messages {
|
||||
@@ -4051,17 +4515,21 @@ input:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-model-section {
|
||||
.agent-core-setting-section {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
min-height: 138px;
|
||||
align-content: start;
|
||||
gap: 9px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 8px;
|
||||
background: #faf9f5;
|
||||
}
|
||||
|
||||
.agent-model-section {
|
||||
min-height: 138px;
|
||||
}
|
||||
|
||||
.agent-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4302,37 +4770,68 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.agent-modification-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.85fr);
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.codex-auto-restore-card {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.codex-auto-restore-toggle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-height: 72px;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 8px;
|
||||
background: #fffdf8;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-modification-actions.enabled {
|
||||
border-color: #b6d3bd;
|
||||
background: #f3f8f4;
|
||||
.codex-auto-restore-switch {
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.agent-modification-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
.codex-auto-restore-switch .switch-track::after {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.agent-modification-copy strong {
|
||||
color: #2d2a26;
|
||||
font-size: 13px;
|
||||
.codex-auto-restore-switch input:checked + .switch-track::after {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.agent-modification-copy > span {
|
||||
color: #7a746c;
|
||||
.codex-auto-restore-switch input:focus-visible + .switch-track {
|
||||
outline: 3px solid rgba(45, 42, 38, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.codex-auto-restore-toggle:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.codex-auto-restore-toggle > strong {
|
||||
min-width: 42px;
|
||||
color: #5f5a53;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.codex-auto-restore-card.enabled .codex-auto-restore-toggle > strong {
|
||||
color: #2f6b3f;
|
||||
}
|
||||
|
||||
.codex-auto-restore-message {
|
||||
max-width: 100%;
|
||||
color: #2f6b3f;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.codex-auto-restore-message.error {
|
||||
color: #9f2d20;
|
||||
}
|
||||
|
||||
.agent-modification-control {
|
||||
@@ -4342,7 +4841,7 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.agent-modification-control > .agent-inline-message {
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-modification-buttons {
|
||||
@@ -5439,11 +5938,6 @@ input:focus-visible {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.agent-modification-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-filter-panel {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -5611,6 +6105,52 @@ input:focus-visible {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.codex-session-metrics {
|
||||
grid-template-columns: repeat(3, minmax(70px, 1fr));
|
||||
}
|
||||
|
||||
.codex-session-database-metric {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-session-repair-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codex-session-repair-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.codex-session-row,
|
||||
.codex-session-row:has(.codex-session-checkbox) {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.codex-session-row:has(.codex-session-checkbox) .codex-session-main {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.codex-session-row:has(.codex-session-checkbox) .codex-session-meta {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-session-meta {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(80px, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codex-session-meta small,
|
||||
.codex-session-meta time {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.codex-session-meta time {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.agents-page,
|
||||
.agent-config-panel {
|
||||
grid-template-rows: auto;
|
||||
@@ -5701,6 +6241,74 @@ input:focus-visible {
|
||||
grid-auto-columns: minmax(104px, 1fr);
|
||||
}
|
||||
|
||||
.codex-session-overview,
|
||||
.codex-session-list-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.codex-session-overview-head,
|
||||
.codex-session-list-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.codex-session-overview-head .compact-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-session-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.codex-session-database-metric {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-session-repair-actions,
|
||||
.codex-session-selection-actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.codex-session-row,
|
||||
.codex-session-row:has(.codex-session-checkbox) {
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-main,
|
||||
.codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-meta,
|
||||
.codex-session-row:not(:has(.codex-session-checkbox)) .codex-session-delete-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-session-main,
|
||||
.codex-session-row:has(.codex-session-checkbox) .codex-session-main {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.codex-session-meta,
|
||||
.codex-session-row:has(.codex-session-checkbox) .codex-session-meta {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.codex-session-meta time {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.codex-session-delete-button {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-session-cleanup-dialog {
|
||||
max-height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
.usage-tabs {
|
||||
width: 100%;
|
||||
grid-auto-columns: 1fr;
|
||||
@@ -5756,6 +6364,16 @@ input:focus-visible {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.codex-auto-restore-toggle {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.codex-auto-restore-toggle > strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-config-footer .primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
codexSessionPageCounts,
|
||||
retainVisibleCodexSessionIds,
|
||||
} from '../src/services/codexSessionState';
|
||||
|
||||
describe('Codex session page state', () => {
|
||||
it('counts active and archived sessions on the current page', () => {
|
||||
expect(codexSessionPageCounts([
|
||||
{ archived: false },
|
||||
{ archived: true },
|
||||
{ archived: false },
|
||||
])).toEqual({ active: 2, archived: 1 });
|
||||
});
|
||||
|
||||
it('drops selections that are no longer on the visible page', () => {
|
||||
expect(Array.from(retainVisibleCodexSessionIds(
|
||||
new Set(['thread-a', 'thread-c']),
|
||||
[{ id: 'thread-a' }, { id: 'thread-b' }],
|
||||
))).toEqual(['thread-a']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user