feat: add Codex session management and automatic restoration

This commit is contained in:
lzt404 committed 2026-07-26 11:54:48 +08:00
1 parent 50b35ae6bd
commit 4e8125b94a
12 files changed
+4425 -69

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.2.99
7.2.100
File diff suppressed because it is too large. Load diff
+39 -2
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+10 -13
View File
@@ -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,8 +860,9 @@ export function AgentsPage() {
</span>
</section>
<section className={`agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
<div className="agent-modification-copy">
<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')
@@ -870,6 +872,7 @@ export function AgentsPage() {
? t('agents.modify.applied')
: t('agents.modify.unconfigured')}</span>
</div>
</div>
<div className="agent-modification-control">
<div className="agent-modification-buttons">
<button
@@ -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>
+114
View File
@@ -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>
);
}
+517
View File
@@ -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>
);
}
+89
View File
@@ -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
View File
@@ -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%;
}
+22
View File
@@ -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']);
});
});