From 50b35ae6bd6b8d82bd90d17c68ebb2fcb827df83 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 25 Jul 2026 20:47:57 +0800 Subject: [PATCH 1/9] feat: redesign agent configuration UI and add Codex session tab - add localized core configuration and session management tabs - display action errors inline and streamline status feedback - refine responsive layouts, dialogs, action buttons, and default window size --- src-tauri/tauri.conf.json | 4 +- src/i18n/ja.ts | 13 +- src/i18n/resources.ts | 26 +- src/i18n/traditional.ts | 1 + src/pages/AgentsPage.tsx | 495 +++++++++++++++++++++++--------------- src/styles.css | 307 +++++++++++++++-------- 6 files changed, 528 insertions(+), 318 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8a36495..0aef32e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,8 +14,8 @@ { "label": "main", "title": "EasyCLIProxyAPI", - "width": 1200, - "height": 810, + "width": 1359, + "height": 865, "minWidth": 640, "minHeight": 600 } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ae6adf8..0fa478a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -360,15 +360,7 @@ export const jaOverrides = { 'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください', 'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません', 'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください', - 'agents.notice.enabled': '{name} の設定変更を適用しました', - 'agents.notice.applied': '{name} の設定変更を適用しました', - 'agents.notice.defaultWritten': '{name} のデフォルト設定を書き込みました', - 'agents.notice.updated': '{name} の設定変更を更新しました', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', - 'agents.notice.appliedLaunch': '{name} にモデルを適用して起動しました', - 'agents.notice.syncedLaunch': '{name} の管理対象設定を同期して起動しました', - 'agents.notice.managedLaunch': '{name} を設定変更が適用された状態で起動しました', - 'agents.notice.launched': '{name} を起動しました', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', 'agents.status.notInstalled': '未インストール', @@ -381,6 +373,11 @@ export const jaOverrides = { 'agents.status.unconfigured': '未設定', 'agents.title': 'エージェント設定', 'agents.redetect': '再検出', + 'agents.tabs.label': 'エージェント設定ページ', + 'agents.tabs.core': 'コア設定', + 'agents.tabs.sessions': 'セッション管理', + 'agents.sessions.title': 'Codex セッション管理', + 'agents.sessions.description': 'セッションの一覧と管理機能はここに表示されます。', 'agents.localClients': 'ローカルクライアント', 'agents.selectClient': '管理するエージェントを選択', 'agents.installStatus': 'インストール状態', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 3235133..97a9fd7 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -363,15 +363,7 @@ export const zhCN = { 'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试', 'agents.error.noModels': '当前没有可选模型,无法应用配置修改', 'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择', - 'agents.notice.enabled': '{name} 的配置修改已应用', - 'agents.notice.applied': '{name} 的配置修改已应用', - 'agents.notice.defaultWritten': '{name} 的默认配置已写入', - 'agents.notice.updated': '{name} 的配置修改已更新', 'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”', - 'agents.notice.appliedLaunch': '{name} 已应用模型并启动', - 'agents.notice.syncedLaunch': '{name} 已同步受管配置并启动', - 'agents.notice.managedLaunch': '{name} 已启动,配置修改仍然生效', - 'agents.notice.launched': '{name} 已启动', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', 'agents.status.notInstalled': '未安装', @@ -384,6 +376,11 @@ export const zhCN = { 'agents.status.unconfigured': '尚未配置', 'agents.title': '智能体配置', 'agents.redetect': '重新检测', + 'agents.tabs.label': '智能体配置子页面', + 'agents.tabs.core': '核心配置', + 'agents.tabs.sessions': '会话管理', + 'agents.sessions.title': 'Codex 会话管理', + 'agents.sessions.description': '会话列表与管理功能将在这里显示。', 'agents.localClients': '本机客户端', 'agents.selectClient': '选择需要管理的智能体', 'agents.installStatus': '安装状态', @@ -1078,15 +1075,7 @@ export const en: Record = { 'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.', 'agents.error.noModels': 'No models are available, so configuration changes cannot be applied', 'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.', - 'agents.notice.enabled': 'Configuration changes for {name} were applied.', - 'agents.notice.applied': 'Configuration changes for {name} were applied.', - 'agents.notice.defaultWritten': 'The default configuration for {name} was written.', - 'agents.notice.updated': 'Configuration changes for {name} were updated.', 'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.', - 'agents.notice.appliedLaunch': '{name} applied the model and started.', - 'agents.notice.syncedLaunch': '{name} synchronized the managed configuration and started.', - 'agents.notice.managedLaunch': '{name} started with the configuration changes still applied.', - 'agents.notice.launched': '{name} started', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', 'agents.status.notInstalled': 'Not Installed', @@ -1099,6 +1088,11 @@ export const en: Record = { 'agents.status.unconfigured': 'Not Configured', 'agents.title': 'Agent Configuration', 'agents.redetect': 'Detect Again', + 'agents.tabs.label': 'Agent configuration sections', + '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.localClients': 'Local Clients', 'agents.selectClient': 'Select an agent to manage', 'agents.installStatus': 'Installation Status', diff --git a/src/i18n/traditional.ts b/src/i18n/traditional.ts index e2d1c14..3bad967 100644 --- a/src/i18n/traditional.ts +++ b/src/i18n/traditional.ts @@ -2,6 +2,7 @@ const phraseReplacements: ReadonlyArray = [ ['繁体中文', '繁體中文'], ['简体中文', '簡體中文'], ['智能体', '智慧代理'], + ['会话', '工作階段'], ['使用记录', '使用記錄'], ['认证文件', '認證檔案'], ['配置文件', '設定檔'], diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 1a693e8..3797cda 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -17,6 +17,7 @@ import { Check, ChevronDown, LoaderCircle, + MessageSquare, Play, RefreshCw, Search, @@ -88,6 +89,14 @@ type AgentDefinition = { descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes'; }; +type AgentSubpageId = 'core' | 'sessions'; + +type AgentSubpageDefinition = { + id: AgentSubpageId; + labelKey: 'agents.tabs.core' | 'agents.tabs.sessions'; + clients?: readonly AgentClientId[]; +}; + const agentDefinitions: AgentDefinition[] = [ { id: 'claude-code', @@ -127,6 +136,20 @@ const agentDefinitions: AgentDefinition[] = [ }, ]; +const agentSubpages: AgentSubpageDefinition[] = [ + { + id: 'core', + labelKey: 'agents.tabs.core', + }, + { + id: 'sessions', + labelKey: 'agents.tabs.sessions', + clients: ['codex'], + }, +]; + +const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core'; + const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1'; const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1'; @@ -455,6 +478,7 @@ function AgentModelPicker({ export function AgentsPage() { const { t } = useI18n(); const [selected, setSelected] = useState(readSelectedAgentClient); + const [activeSubpage, setActiveSubpage] = useState(DEFAULT_AGENT_SUBPAGE); const [statuses, setStatuses] = useState([]); const [models, setModels] = useState([]); const [modelByClient, setModelByClient] = useState>>( @@ -465,9 +489,12 @@ export function AgentsPage() { const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'launch' | null>(null); const busy = busyAction !== null; - const [error, setError] = useState(''); + const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); - const [notice, setNotice] = useState(''); + const [modelSelectionError, setModelSelectionError] = useState(''); + const [configurationError, setConfigurationError] = useState(''); + const [launchError, setLaunchError] = useState(''); + const [defaultError, setDefaultError] = useState(''); const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false); const loadStatuses = useCallback(async (forceRefresh = false) => { @@ -484,6 +511,7 @@ export function AgentsPage() { try { const nextModels = await invoke('get_agent_models'); setModels(nextModels); + setModelSelectionError(''); setModelByClient((current) => { const next = reconcileAgentModelSelections(current, nextModels); writeAgentModelSelections(next); @@ -498,11 +526,11 @@ export function AgentsPage() { const refresh = useCallback(async () => { setLoading(true); - setError(''); + setDetectionError(''); try { await Promise.all([loadStatuses(true), loadModels()]); } catch (requestError) { - setError(String(requestError)); + setDetectionError(String(requestError)); } finally { setLoading(false); } @@ -510,9 +538,9 @@ export function AgentsPage() { useEffect(() => { setLoading(true); - setError(''); + setDetectionError(''); void Promise.all([loadStatuses(), loadModels()]) - .catch((requestError) => setError(String(requestError))) + .catch((requestError) => setDetectionError(String(requestError))) .finally(() => setLoading(false)); }, [loadModels, loadStatuses]); @@ -521,8 +549,9 @@ export function AgentsPage() { let stop: (() => void) | null = null; void listen('config-files-changed', () => { if (disposed) return; + setDetectionError(''); void Promise.all([loadStatuses(true), loadModels()]).catch((requestError) => { - if (!disposed) setError(String(requestError)); + if (!disposed) setDetectionError(String(requestError)); }); }).then((unlisten) => { if (disposed) unlisten(); @@ -538,6 +567,15 @@ export function AgentsPage() { writeSelectedAgentClient(selected); }, [selected]); + useEffect(() => { + setActiveSubpage(DEFAULT_AGENT_SUBPAGE); + setModelSelectionError(''); + setConfigurationError(''); + setLaunchError(''); + setDefaultError(''); + setDefaultConfirmOpen(false); + }, [selected]); + useEffect(() => { setLaunchTargetByClient((current) => agentDefinitions.reduce>>( (next, definition) => { @@ -587,9 +625,19 @@ export function AgentsPage() { void loadModels(); }; + const reloadStatusesAfterAction = async () => { + setDetectionError(''); + try { + await loadStatuses(true); + } catch (requestError) { + setDetectionError(String(requestError)); + } + }; + const selectModel = (value: string) => { const model = findAgentModel(models, value); if (!model) return; + setModelSelectionError(''); setModelByClient((current) => { const next = { ...current, [selected]: model.name }; writeAgentModelSelections(next); @@ -599,36 +647,35 @@ export function AgentsPage() { const requireSelectedModel = () => { if (modelLoading) { - setError(t('agents.error.modelsLoading')); + setModelSelectionError(t('agents.error.modelsLoading')); return null; } if (models.length === 0) { - setError(modelError || t('agents.error.noModels')); + setModelSelectionError(modelError || t('agents.error.noModels')); return null; } const model = findAgentModel(models, selectedModel); if (!model) { - setError(t('agents.error.selectionGone')); + setModelSelectionError(t('agents.error.selectionGone')); return null; } + setModelSelectionError(''); return model.name; }; const applyConfigurationChanges = async () => { + setConfigurationError(''); const model = requireSelectedModel(); if (!model) return; setBusyAction('apply'); - setError(''); - setNotice(''); try { await invoke('apply_agent_config', { client: selected, model, }); - setNotice(t('agents.notice.applied', { name: activeDefinition.name })); - await loadStatuses(true); + await reloadStatusesAfterAction(); } catch (requestError) { - setError(String(requestError)); + setConfigurationError(String(requestError)); } finally { setBusyAction(null); } @@ -638,18 +685,16 @@ export function AgentsPage() { const model = requireSelectedModel(); if (!model) return; setBusyAction('default'); - setError(''); - setNotice(''); + setDefaultError(''); try { await invoke('reset_agent_config_to_default', { client: selected, model, }); setDefaultConfirmOpen(false); - setNotice(t('agents.notice.defaultWritten', { name: activeDefinition.name })); - await loadStatuses(true); + await reloadStatusesAfterAction(); } catch (requestError) { - setError(String(requestError)); + setDefaultError(String(requestError)); } finally { setBusyAction(null); } @@ -657,41 +702,32 @@ export function AgentsPage() { const launchAgent = async () => { setBusyAction('launch'); - setError(''); - setNotice(''); + setLaunchError(''); try { if (draftChanged) { throw new Error(t('agents.error.applyFirst')); } await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id }); - setNotice(t('agents.notice.managedLaunch', { name: activeDefinition.name })); } catch (requestError) { - setError(String(requestError)); + setLaunchError(String(requestError)); } finally { setBusyAction(null); } }; - const statusLabel = loading - ? t('agents.status.detecting') - : !activeStatus?.supportedPlatform - ? t('agents.status.unsupported') - : !activeStatus.installed - ? t('agents.status.notInstalled') - : activeStatus.modificationState === 'external-changed' - ? t('agents.status.externalChanged') - : activeStatus.modificationState === 'invalid' - ? t('agents.status.invalid') - : activeStatus.modificationState === 'applied' - ? t('agents.status.applied') - : t('agents.status.unconfigured'); - const statusTone = activeStatus?.modificationState === 'external-changed' - || activeStatus?.modificationState === 'invalid' - || !activeStatus?.supportedPlatform - ? 'error' - : activeStatus?.modificationState === 'applied' - ? 'success' - : ''; + const openDefaultConfirmation = () => { + setDefaultError(''); + setDefaultConfirmOpen(true); + }; + + const closeDefaultConfirmation = () => { + setDefaultError(''); + setDefaultConfirmOpen(false); + }; + + const availableSubpages = agentSubpages.filter( + (subpage) => !subpage.clients || subpage.clients.includes(selected), + ); return (
@@ -700,17 +736,19 @@ export function AgentsPage() { Agent Clients

{t('agents.title')}

- +
+ {detectionError ? ( + + {detectionError} + + ) : null} + +
-
- {error ?
{error}
: null} - {!error && notice ?
{notice}
: null} -
-
-
-
- -

{activeDefinition.name}

{t(activeDefinition.descriptionKey)}
-
- {statusLabel} -
- -
- {activeStatus?.error ?
{activeStatus.error}
: null} - {activeStatus?.warnings.length && !activeStatus.error ? ( -
{activeStatus.warnings.join(';')}
- ) : null} -
- -
-
- {t('agents.installStatus')} - {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')} -
-
- {t('agents.clientVersion')} - {activeStatus?.version ?? t('agents.notFetched')} -
-
- -
-
-
{t('agents.useModel')}{t('agents.useModelDescription')}
- {draftChanged ? {t('agents.pending')} : null} -
- - - {modelLoading - ? t('agents.model.readingAvailable') - : modelError && models.length === 0 - ? modelError - : models.length === 0 - ? t('agents.model.cannotConfigure') - : activeStatus?.modificationState === 'applied' - ? t('agents.model.current', { model: appliedModel || '—' }) - : t('agents.model.firstSelection', { count: models.length })} - -
- -
-
- {t('agents.modify.title')} - {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')} -
-
+
+ {availableSubpages.map((subpage) => ( - -
-
+ ))} +
-
-
- {activeStatus?.modificationState === 'applied' ? : } - {activeStatus?.modificationState === 'applied' - ? draftChanged - ? t('agents.footer.changed') - : t('agents.footer.applied') - : activeStatus?.modificationState === 'external-changed' - ? t('agents.footer.externalChanged') - : activeStatus?.modificationState === 'invalid' - ? t('agents.footer.invalidManaged') - : !activeStatus?.supportedPlatform - ? t('agents.footer.unsupported') - : !activeStatus.installed - ? t('agents.footer.installFirst') - : activeStatus.launchTargets.length === 0 - ? t('agents.footer.noCommand') - : t('agents.footer.enableFirst')} -
-
- {activeLaunchTargets.length > 1 ? ( -
- {activeLaunchTargets.map((target) => ( - - ))} + {activeSubpage === 'core' ? ( +
+
+
+ {t('agents.installStatus')} + {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')} +
+
+ {t('agents.clientVersion')} + {activeStatus?.version ?? t('agents.notFetched')} +
+
+ + {activeStatus?.error || activeStatus?.warnings.length ? ( +
+ {activeStatus.error ? ( + {activeStatus.error} + ) : ( + {activeStatus.warnings.join(';')} + )}
) : null} - + +
+
+
{t('agents.useModel')}{t('agents.useModelDescription')}
+ {draftChanged ? {t('agents.pending')} : null} +
+ + + {modelSelectionError + || modelError + || (modelLoading + ? t('agents.model.readingAvailable') + : models.length === 0 + ? t('agents.model.cannotConfigure') + : activeStatus?.modificationState === 'applied' + ? t('agents.model.current', { model: appliedModel || '—' }) + : t('agents.model.firstSelection', { count: models.length }))} + +
+ +
+
+ {t('agents.modify.title')} + {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')} +
+
+
+ + +
+ {configurationError ? ( + + {configurationError} + + ) : null} +
+
+ +
+
+ {activeStatus?.modificationState === 'applied' ? : } + {activeStatus?.modificationState === 'applied' + ? draftChanged + ? t('agents.footer.changed') + : t('agents.footer.applied') + : activeStatus?.modificationState === 'external-changed' + ? t('agents.footer.externalChanged') + : activeStatus?.modificationState === 'invalid' + ? t('agents.footer.invalidManaged') + : !activeStatus?.supportedPlatform + ? t('agents.footer.unsupported') + : !activeStatus.installed + ? t('agents.footer.installFirst') + : activeStatus.launchTargets.length === 0 + ? t('agents.footer.noCommand') + : t('agents.footer.enableFirst')} +
+
+
+ {activeLaunchTargets.length > 1 ? ( +
+ {activeLaunchTargets.map((target) => ( + + ))} +
+ ) : null} + +
+ {launchError ? ( + + {launchError} + + ) : null} +
+
-
+ ) : null} + + {selected === 'codex' && activeSubpage === 'sessions' ? ( +
+
+ +
+

{t('agents.sessions.title')}

+

{t('agents.sessions.description')}

+
+
+
+ ) : null}
@@ -901,8 +1001,13 @@ export function AgentsPage() {

{t('agents.default.description', { name: activeDefinition.name })}

+ {defaultError ? ( + + {defaultError} + + ) : null}
- +
) : null} -
+
{t('agents.useModel')}{t('agents.useModelDescription')}
{draftChanged ? {t('agents.pending')} : null} @@ -859,16 +860,18 @@ export function AgentsPage() {
-
-
- {t('agents.modify.title')} - {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')} +
+
+
+ {t('agents.modify.title')} + {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')} +
@@ -902,6 +905,8 @@ export function AgentsPage() {
+ {selected === 'codex' ? : null} +
{activeStatus?.modificationState === 'applied' ? : } @@ -978,15 +983,7 @@ export function AgentsPage() { role="tabpanel" aria-labelledby="agent-subpage-tab-sessions" > -
- -
-

{t('agents.sessions.title')}

-

{t('agents.sessions.description')}

-
-
+
) : null}
diff --git a/src/pages/CodexSessionAutoRestoreCard.tsx b/src/pages/CodexSessionAutoRestoreCard.tsx new file mode 100644 index 0000000..2bb85be --- /dev/null +++ b/src/pages/CodexSessionAutoRestoreCard.tsx @@ -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(null); + + useEffect(() => { + let disposed = false; + void invoke('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('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 ( +
+
+
+ {t('agents.sessions.autoRepair')} + {t('agents.sessions.autoRestoreDescription')} +
+ +
+ {status ? ( + + {status.text} + + ) : null} +
+ ); +} diff --git a/src/pages/CodexSessionsPanel.tsx b/src/pages/CodexSessionsPanel.tsx new file mode 100644 index 0000000..ca0855c --- /dev/null +++ b/src/pages/CodexSessionsPanel.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [operation, setOperation] = useState<'delete' | 'repair' | 'preview' | 'cleanup' | null>(null); + const [notice, setNotice] = useState(null); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [deleteConfirmation, setDeleteConfirmation] = useState(null); + const [cleanupPreview, setCleanupPreview] = useState(null); + const [cleanupSelectedIds, setCleanupSelectedIds] = useState>(() => new Set()); + const [repairProgress, setRepairProgress] = useState({ + 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('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('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('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('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('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('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 ( +
+
+
+
+ {t('agents.sessions.title')} + {t('agents.sessions.description')} +
+ +
+ +
+
{t('agents.sessions.currentPage')}{sessions.length}
+
{t('agents.sessions.active')}{pageCounts.active}
+
{t('agents.sessions.archived')}{pageCounts.archived}
+
+ {t('agents.sessions.database')} + {page?.databasePaths[0] || t('agents.sessions.databaseMissing')} +
+
+ +
+
+ +
+ {t('agents.sessions.repairTitle')} + {t('agents.sessions.repairDescription')} +
+
+
+ + +
+ {operation === 'repair' ? ( +
+
+ + {repairPhaseText} + {repairProgress.total > 0 + ? ` · ${t('agents.sessions.progressCount', { processed: repairProgress.processed, total: repairProgress.total })}` + : ''} + + {repairProgress.percent}% +
+
+ +
+
+ ) : null} +
+ +
+ + {notice ? ( +
+ {notice.kind === 'error' ? : notice.kind === 'warning' ? : } + {notice.message} + +
+ ) : null} + +
+
+
+ {t('agents.sessions.localSessions')} + {t('agents.sessions.pageDescription', { page: currentPage, size: page?.limit ?? PAGE_SIZE })} +
+
+ {selectionMode ? {t('agents.sessions.selected', { count: selectedSessions.length })} : null} + {selectionMode ? ( + <> + + + + + ) : null} + +
+
+ + {loading ? ( +
{t('agents.sessions.loading')}
+ ) : sessions.length === 0 ? ( +
{t('agents.sessions.empty')}
+ ) : ( +
+ {sessions.map((session) => ( +
+ {selectionMode ? ( + + ) : null} +
+ {session.title || t('agents.sessions.untitled')} + {session.id} + {session.cwd || t('agents.sessions.noProject')} +
+
+ {session.archived ? t('agents.sessions.archivedBadge') : t('agents.sessions.activeBadge')} + {session.modelProvider || t('agents.sessions.noProvider')} + +
+ +
+ ))} +
+ )} + +
+ + {t('agents.sessions.pageNumber', { page: currentPage })} + +
+
+ + {deleteConfirmation ? ( +
+
+

{deleteConfirmation.title}

+

{deleteConfirmation.description}

+
+ + +
+
+
+ ) : null} + + {cleanupPreview ? ( +
+
+
+

{t('agents.sessions.cleanupTitle')}

+ +
+

{t('agents.sessions.cleanupDescription', { count: cleanupPreview.candidates.length })}

+ +
+ {cleanupPreview.candidates.map((candidate) => ( + + ))} +
+
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/src/services/codexSessionState.ts b/src/services/codexSessionState.ts new file mode 100644 index 0000000..20c99ce --- /dev/null +++ b/src/services/codexSessionState.ts @@ -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[], +): CodexSessionPageCounts { + const archived = sessions.reduce( + (count, session) => count + Number(session.archived), + 0, + ); + return { active: sessions.length - archived, archived }; +} + +export function retainVisibleCodexSessionIds( + selectedIds: ReadonlySet, + sessions: readonly Pick[], +): Set { + const visibleIds = new Set(sessions.map((session) => session.id)); + return new Set(Array.from(selectedIds).filter((id) => visibleIds.has(id))); +} diff --git a/src/styles.css b/src/styles.css index 484d404..3689a25 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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%; } diff --git a/tests/codexSessionState.test.ts b/tests/codexSessionState.test.ts new file mode 100644 index 0000000..670e52c --- /dev/null +++ b/tests/codexSessionState.test.ts @@ -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']); + }); +}); From e6a4c8a75562b9a80d1e1b025d6e63ceff4e9dcb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 26 Jul 2026 12:10:22 +0800 Subject: [PATCH 3/9] feat: add Codex configuration cleanup action --- src-tauri/src/main.rs | 69 ++++++++++++++++++++++++++++++++++++ src/i18n/ja.ts | 5 +++ src/i18n/resources.ts | 10 ++++++ src/pages/AgentsPage.tsx | 76 ++++++++++++++++++++++++++++++++++++++-- src/styles.css | 8 +++++ 5 files changed, 166 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3bd2669..2fc1d51 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1653,6 +1653,18 @@ async fn reset_agent_config_to_default( ) } +#[tauri::command] +fn clear_codex_config(app: tauri::AppHandle) -> Result, String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + clear_codex_config_files(&home) +} + #[tauri::command] async fn set_agent_config_enabled( app: tauri::AppHandle, @@ -2137,6 +2149,37 @@ fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { } } +fn remove_codex_config_file(path: &Path) -> Result { + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "删除 Codex 配置文件失败 {}: {error}", + path_to_string(path) + )), + } +} + +fn clear_codex_config_files(home: &Path) -> Result, String> { + let codex_dir = home.join(".codex"); + let config_path = codex_dir.join("config.toml"); + let targets = [codex_dir.join("auth.json"), config_path.clone()]; + let mut deleted = Vec::new(); + + for path in targets { + if remove_codex_config_file(&path)? { + deleted.push(path_to_string(&path)); + } + } + + // The state file belongs to this app. Remove it after the Codex files are + // cleared so the UI does not report the deliberate deletion as an external edit. + let state_path = agent_state_path(std::slice::from_ref(&config_path))?; + remove_codex_config_file(&state_path)?; + + Ok(deleted) +} + fn codex_model_catalog_path(home: &Path) -> PathBuf { home.join(".codex").join(CODEX_MODEL_CATALOG_FILE) } @@ -12782,6 +12825,7 @@ fn main() { delete_thinking_alias, apply_agent_config, reset_agent_config_to_default, + clear_codex_config, set_agent_config_enabled, update_agent_config, launch_agent, @@ -12993,6 +13037,31 @@ mod tests { fs::remove_dir_all(home).unwrap(); } + #[test] + fn clear_codex_config_removes_only_requested_config_files_and_application_state() { + let home = agent_test_home("clear-codex-config"); + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let auth_path = codex_dir.join("auth.json"); + let config_path = codex_dir.join("config.toml"); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + let preserved_path = codex_dir.join("history.jsonl"); + fs::write(&auth_path, "{}").unwrap(); + fs::write(&config_path, "model = \"gpt-test\"\n").unwrap(); + fs::write(&state_path, "{}").unwrap(); + fs::write(&preserved_path, "keep").unwrap(); + + let deleted = clear_codex_config_files(&home).unwrap(); + + assert_eq!(deleted.len(), 2); + assert!(!auth_path.exists()); + assert!(!config_path.exists()); + assert!(!state_path.exists()); + assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep"); + assert!(clear_codex_config_files(&home).unwrap().is_empty()); + fs::remove_dir_all(home).unwrap(); + } + #[test] fn direct_agent_apply_rebuilds_an_unparseable_file_without_backup() { let home = agent_test_home("invalid-apply"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 76538f3..7d19e23 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -462,6 +462,7 @@ export const jaOverrides = { 'agents.modify.title': '設定変更', 'agents.modify.apply': '設定変更を適用', 'agents.modify.default': 'デフォルト設定', + 'agents.modify.clear': '設定を消去', 'agents.modify.externalChanged': '外部の設定変更を検出し、その内容を保持しています。「設定変更を適用」をクリックするまで管理対象フィールドは書き換えません。', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', 'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。', @@ -484,6 +485,10 @@ export const jaOverrides = { 'agents.default.title': 'デフォルト設定にリセット', 'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。バックアップは作成されず、元に戻せません。', 'agents.default.confirm': 'デフォルト設定を書き込む', + 'agents.clear.title': 'Codex 設定を消去', + 'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除し、このアプリが記録した適用状態をリセットします。バックアップは作成されず、元に戻せません。', + 'agents.clear.confirm': '設定を消去', + 'agents.clear.success': 'Codex 設定を消去しました。', 'apiAccess.compatibleName': 'OpenAI 互換 {number}', 'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません', 'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 4b97b44..194334f 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -465,6 +465,7 @@ export const zhCN = { 'agents.modify.title': '配置修改', 'agents.modify.apply': '应用配置修改', 'agents.modify.default': '默认配置', + 'agents.modify.clear': '清空配置', 'agents.modify.externalChanged': '检测到外部配置变化。软件会保留这些内容;点击“应用配置修改”后才会重新写入受管字段。', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', 'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。', @@ -487,6 +488,10 @@ export const zhCN = { 'agents.default.title': '重置为默认配置', 'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。', 'agents.default.confirm': '确认写入默认配置', + 'agents.clear.title': '清空 Codex 配置', + 'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml,并重置软件记录的应用状态。该操作没有备份且无法恢复。', + 'agents.clear.confirm': '确认清空配置', + 'agents.clear.success': 'Codex 配置已清空。', 'apiAccess.compatibleName': 'OpenAI 兼容 {number}', 'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号', 'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效', @@ -1244,6 +1249,7 @@ export const en: Record = { 'agents.modify.title': 'Configuration Changes', 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.default': 'Default Configuration', + 'agents.modify.clear': 'Clear Configuration', 'agents.modify.externalChanged': 'External configuration changes were detected and preserved. Managed fields are rewritten only after you click Apply Configuration Changes.', 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', 'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.', @@ -1266,6 +1272,10 @@ export const en: Record = { 'agents.default.title': 'Reset to Default Configuration', 'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.', 'agents.default.confirm': 'Write Default Configuration', + 'agents.clear.title': 'Clear Codex Configuration', + 'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory and resets the application state tracked by this app. No backup is created and the operation cannot be undone.', + 'agents.clear.confirm': 'Clear Configuration', + 'agents.clear.success': 'The Codex configuration has been cleared.', 'apiAccess.compatibleName': 'OpenAI Compatible {number}', 'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon', 'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index a99ae05..1344e11 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -21,6 +21,7 @@ import { RefreshCw, Search, Sparkles, + Trash2, X, } from 'lucide-react'; import claudeIcon from '../assets/icons/claude.svg'; @@ -488,7 +489,7 @@ export function AgentsPage() { const [launchTargetByClient, setLaunchTargetByClient] = useState>>({}); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); - const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'launch' | null>(null); + const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | null>(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); @@ -497,6 +498,9 @@ export function AgentsPage() { const [launchError, setLaunchError] = useState(''); const [defaultError, setDefaultError] = useState(''); const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false); + const [clearError, setClearError] = useState(''); + const [clearNotice, setClearNotice] = useState(''); + const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -575,6 +579,9 @@ export function AgentsPage() { setLaunchError(''); setDefaultError(''); setDefaultConfirmOpen(false); + setClearError(''); + setClearNotice(''); + setClearConfirmOpen(false); }, [selected]); useEffect(() => { @@ -701,6 +708,22 @@ export function AgentsPage() { } }; + const clearCodexConfiguration = async () => { + setBusyAction('clear'); + setClearError(''); + setClearNotice(''); + try { + await invoke('clear_codex_config'); + setClearConfirmOpen(false); + setClearNotice(t('agents.clear.success')); + await reloadStatusesAfterAction(); + } catch (requestError) { + setClearError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const launchAgent = async () => { setBusyAction('launch'); setLaunchError(''); @@ -726,6 +749,16 @@ export function AgentsPage() { setDefaultConfirmOpen(false); }; + const openClearConfirmation = () => { + setClearError(''); + setClearConfirmOpen(true); + }; + + const closeClearConfirmation = () => { + setClearError(''); + setClearConfirmOpen(false); + }; + const availableSubpages = agentSubpages.filter( (subpage) => !subpage.clients || subpage.clients.includes(selected), ); @@ -874,7 +907,7 @@ export function AgentsPage() {
-
+
+ {selected === 'codex' ? ( + + ) : null}
{configurationError ? ( {configurationError} ) : null} + {clearNotice ? ( + + {clearNotice} + + ) : null}
@@ -1013,6 +1062,29 @@ export function AgentsPage() { ) : null} + + {clearConfirmOpen ? ( +
+
+
+

{t('agents.clear.title')}

+
+

{t('agents.clear.description')}

+ {clearError ? ( + + {clearError} + + ) : null} +
+ + +
+
+
+ ) : null} ); } diff --git a/src/styles.css b/src/styles.css index 3689a25..f7425d9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4851,6 +4851,10 @@ input:focus-visible { width: 100%; } +.agent-modification-buttons.codex { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .agent-modification-buttons button { width: 100%; min-width: 0; @@ -6192,6 +6196,10 @@ input:focus-visible { } @media (max-width: 620px) { + .agent-modification-buttons.codex { + grid-template-columns: 1fr; + } + .config-side-stack { grid-template-columns: 1fr; grid-template-rows: repeat(2, 206px); From 7f19f4bbc8a7f86181676e6aa13c573094817960 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 26 Jul 2026 12:33:35 +0800 Subject: [PATCH 4/9] style: decouple agent sidebar and content heights --- src/styles.css | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/styles.css b/src/styles.css index f7425d9..ae3cbde 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3722,12 +3722,15 @@ input:focus-visible { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 16px; - align-items: stretch; + align-items: start; +} + +.agent-client-list { + min-height: 960px; } -.agent-client-list, .agent-config-panel { - min-height: 540px; + min-height: 0; } .agents-page { @@ -3871,7 +3874,7 @@ input:focus-visible { .agent-config-panel { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto; align-content: start; gap: 14px; } From a8a0d54d906143a86b64c2f8bdc254f404d07fa5 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 26 Jul 2026 20:24:41 +0800 Subject: [PATCH 5/9] feat: improve Codex launch controls and add ChatGPT app shutdown --- src-tauri/src/codex_sessions.rs | 175 +++++++++++++-- src-tauri/src/main.rs | 18 +- src/i18n/ja.ts | 16 +- src/i18n/resources.ts | 32 +-- src/pages/AgentsPage.tsx | 191 ++++++++++++----- src/pages/CodexSessionAutoRestoreCard.tsx | 1 - src/styles.css | 248 ++++++++++++++++------ 7 files changed, 526 insertions(+), 155 deletions(-) diff --git a/src-tauri/src/codex_sessions.rs b/src-tauri/src/codex_sessions.rs index e505f58..e7b8eee 100644 --- a/src-tauri/src/codex_sessions.rs +++ b/src-tauri/src/codex_sessions.rs @@ -152,6 +152,13 @@ pub(crate) struct SessionIndexCleanupResult { backup_path: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatGptCloseResult { + was_running: bool, + closed_processes: usize, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SetRepairOnLaunchRequest { @@ -315,6 +322,13 @@ pub(crate) fn set_codex_session_repair_on_launch( }) } +#[tauri::command] +pub(crate) async fn close_chatgpt_app() -> Result { + tauri::async_runtime::spawn_blocking(close_chatgpt_app_processes) + .await + .map_err(|error| format!("关闭 ChatGPT App 任务失败: {error}"))? +} + pub(crate) fn repair_before_codex_launch( user_home: &Path, ) -> Result { @@ -2099,10 +2113,20 @@ fn ensure_codex_apps_stopped() -> Result<(), String> { #[cfg(target_os = "windows")] fn codex_app_process_ids() -> Result, String> { + windows_app_process_ids(&["ChatGPT.exe", "Codex.exe"]) +} + +#[cfg(target_os = "windows")] +fn chatgpt_app_process_ids() -> Result, String> { + windows_app_process_ids(&["ChatGPT.exe"]) +} + +#[cfg(target_os = "windows")] +fn windows_app_process_ids(names: &[&str]) -> Result, String> { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; let mut ids = Vec::new(); - for name in ["ChatGPT.exe", "Codex.exe"] { + for name in names { let filter = format!("IMAGENAME eq {name}"); let output = Command::new("tasklist") .args(["/FI", &filter, "/FO", "CSV", "/NH"]) @@ -2115,28 +2139,42 @@ fn codex_app_process_ids() -> Result, String> { String::from_utf8_lossy(&output.stderr).trim() )); } - ids.extend( - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - let columns = line - .trim() - .trim_matches('"') - .split("\",\"") - .collect::>(); - columns.get(1)?.parse::().ok() - }), - ); + ids.extend(parse_windows_tasklist_process_ids(&output.stdout)); } ids.sort_unstable(); ids.dedup(); Ok(ids) } +#[cfg(target_os = "windows")] +fn parse_windows_tasklist_process_ids(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .filter_map(|line| { + let columns = line + .trim() + .trim_matches('"') + .split("\",\"") + .collect::>(); + columns.get(1)?.parse::().ok() + }) + .collect() +} + #[cfg(not(target_os = "windows"))] fn codex_app_process_ids() -> Result, String> { + unix_app_process_ids(&["ChatGPT", "Codex"]) +} + +#[cfg(not(target_os = "windows"))] +fn chatgpt_app_process_ids() -> Result, String> { + unix_app_process_ids(&["ChatGPT"]) +} + +#[cfg(not(target_os = "windows"))] +fn unix_app_process_ids(names: &[&str]) -> Result, String> { let mut ids = Vec::new(); - for name in ["ChatGPT", "Codex"] { + for name in names { let output = Command::new("pgrep") .args(["-x", name]) .output() @@ -2161,6 +2199,108 @@ fn codex_app_process_ids() -> Result, String> { Ok(ids) } +#[cfg(target_os = "windows")] +fn close_chatgpt_app_processes() -> Result { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let process_ids = chatgpt_app_process_ids()?; + if process_ids.is_empty() { + return Ok(ChatGptCloseResult { + was_running: false, + closed_processes: 0, + }); + } + + let output = Command::new("taskkill") + .args(["/IM", "ChatGPT.exe", "/T", "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|error| format!("无法关闭 ChatGPT.exe: {error}"))?; + let mut remaining = chatgpt_app_process_ids()?; + for _ in 0..20 { + if remaining.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + remaining = chatgpt_app_process_ids()?; + } + if !remaining.is_empty() { + let detail = String::from_utf8_lossy(if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }); + return Err(format!( + "ChatGPT App 未能完全关闭(剩余进程:{}){}{}", + remaining + .iter() + .map(u32::to_string) + .collect::>() + .join("、"), + if detail.trim().is_empty() { "" } else { ":" }, + detail.trim() + )); + } + + Ok(ChatGptCloseResult { + was_running: true, + closed_processes: process_ids.len(), + }) +} + +#[cfg(not(target_os = "windows"))] +fn close_chatgpt_app_processes() -> Result { + let process_ids = chatgpt_app_process_ids()?; + if process_ids.is_empty() { + return Ok(ChatGptCloseResult { + was_running: false, + closed_processes: 0, + }); + } + + let mut terminate = Command::new("kill"); + terminate.arg("-TERM"); + for process_id in &process_ids { + terminate.arg(process_id.to_string()); + } + let _ = terminate.status(); + + for _ in 0..20 { + if chatgpt_app_process_ids()?.is_empty() { + return Ok(ChatGptCloseResult { + was_running: true, + closed_processes: process_ids.len(), + }); + } + std::thread::sleep(Duration::from_millis(100)); + } + + let remaining = chatgpt_app_process_ids()?; + let mut force = Command::new("kill"); + force.arg("-KILL"); + for process_id in &remaining { + force.arg(process_id.to_string()); + } + let _ = force.status(); + std::thread::sleep(Duration::from_millis(100)); + let remaining = chatgpt_app_process_ids()?; + if !remaining.is_empty() { + return Err(format!( + "ChatGPT App 未能完全关闭(剩余进程:{})", + remaining + .iter() + .map(u32::to_string) + .collect::>() + .join("、") + )); + } + + Ok(ChatGptCloseResult { + was_running: true, + closed_processes: process_ids.len(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2168,6 +2308,13 @@ mod tests { static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + #[cfg(target_os = "windows")] + #[test] + fn windows_tasklist_parser_accepts_csv_rows_and_ignores_status_messages() { + let output = b"\"ChatGPT.exe\",\"1234\",\"Console\",\"1\",\"10,000 K\"\r\nINFO: No tasks are running which match the specified criteria.\r\n\"ChatGPT.exe\",\"5678\",\"Console\",\"1\",\"20,000 K\"\r\n"; + assert_eq!(parse_windows_tasklist_process_ids(output), vec![1234, 5678]); + } + fn test_root(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "easy-cli-proxy-codex-sessions-{name}-{}-{}", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2fc1d51..77199da 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1773,7 +1773,7 @@ async fn launch_agent( status.modification_enabled, &status.modification_state, )?; - if client == AgentClient::Codex && config.codex_session_repair_on_launch { + if client == AgentClient::Codex && config.codex_session_repair_on_launch && status.configured { let repair_home = home.clone(); tauri::async_runtime::spawn_blocking(move || { codex_sessions::repair_before_codex_launch(&repair_home) @@ -1827,7 +1827,7 @@ fn validate_agent_launch_modification( enabled: bool, state: &str, ) -> Result<(), String> { - if enabled && state == "applied" { + if client == AgentClient::Codex || (enabled && state == "applied") { return Ok(()); } Err(format!( @@ -12869,7 +12869,8 @@ fn main() { 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 + codex_sessions::set_codex_session_repair_on_launch, + codex_sessions::close_chatgpt_app ]) .build(tauri::generate_context!()) .expect("failed to build app"); @@ -15437,13 +15438,20 @@ custom_option = "keep-original" } #[test] - fn agent_launch_requires_an_active_managed_configuration() { + fn codex_launch_is_independent_from_managed_configuration_state() { assert!(validate_agent_launch_modification(AgentClient::Codex, true, "applied").is_ok()); - assert!(validate_agent_launch_modification(AgentClient::Codex, false, "applied").is_err()); + assert!( + validate_agent_launch_modification(AgentClient::Codex, false, "unconfigured").is_ok() + ); assert!( validate_agent_launch_modification(AgentClient::Codex, true, "external-changed",) + .is_ok() + ); + assert!( + validate_agent_launch_modification(AgentClient::OpenCode, false, "unconfigured") .is_err() ); + assert!(validate_agent_launch_modification(AgentClient::OpenCode, true, "applied").is_ok()); } #[test] diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7d19e23..9880d02 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -387,7 +387,6 @@ export const jaOverrides = { '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': '読み込み中', @@ -453,10 +452,8 @@ export const jaOverrides = { 'agents.clientVersion': 'クライアントバージョン', 'agents.notFetched': '未取得', 'agents.useModel': '使用モデル', - 'agents.useModelDescription': 'コアで公開中のモデルを同期し、選択したモデルを既定に設定します', 'agents.pending': '未適用', 'agents.model.readingAvailable': '利用可能なモデルを読み込んでいます', - 'agents.model.cannotConfigure': '選択可能なモデルがないため、設定変更を適用できません', 'agents.model.current': '現在適用中:{model}', 'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します', 'agents.modify.title': '設定変更', @@ -466,22 +463,22 @@ export const jaOverrides = { 'agents.modify.externalChanged': '外部の設定変更を検出し、その内容を保持しています。「設定変更を適用」をクリックするまで管理対象フィールドは書き換えません。', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', 'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。', - 'agents.modify.unconfigured': 'モデルを選択して設定変更を適用してください。設定のコピーやバックアップは作成しません。', - 'agents.footer.changed': 'モデル選択が変更されました。先に「設定変更を適用」をクリックしてください', + 'agents.footer.changed': 'モデル選択が変更されました。設定変更を適用すると新しいモデルが使用されます。', 'agents.footer.applied': '設定変更は適用済みです。アプリは関連フィールドだけを管理します。', - 'agents.footer.externalChanged': '外部変更を保持しています。再適用するまでこのクライアントは起動できません。', + 'agents.footer.externalChanged': '外部変更を保持しています。設定変更を適用すると管理対象フィールドが再度書き込まれます。', 'agents.footer.invalidManaged': '設定が無効です。変更を適用して修復するか、デフォルト設定にリセットしてください。', 'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません', 'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください', 'agents.footer.noCommand': '設定ファイルは検出しましたが、クライアントコマンドが見つかりません', 'agents.footer.invalidConfig': '元の設定形式が無効なため、安全に変更できません', - 'agents.footer.enableFirst': '先に「設定変更を適用」をクリックし、CPA モデル設定を反映してからクライアントを起動してください', 'agents.launchMethods': 'Codex の起動方法', 'agents.launch.enableFirst': '先に設定変更を適用してください', 'agents.launch.applyFirst': '先に選択中のモデルを適用してください', 'agents.launch.starting': '起動中', 'agents.launch.start': '{target} を起動', 'agents.launch.unavailable': '起動できません', + 'agents.launch.closeChatgpt': 'ChatGPT App を終了', + 'agents.launch.closingChatgpt': '終了中', 'agents.default.title': 'デフォルト設定にリセット', 'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。バックアップは作成されず、元に戻せません。', 'agents.default.confirm': 'デフォルト設定を書き込む', @@ -489,6 +486,11 @@ export const jaOverrides = { 'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除し、このアプリが記録した適用状態をリセットします。バックアップは作成されず、元に戻せません。', 'agents.clear.confirm': '設定を消去', 'agents.clear.success': 'Codex 設定を消去しました。', + 'agents.closeApp.title': 'ChatGPT App を終了', + 'agents.closeApp.description': 'このコンピューターで実行中の ChatGPT App プロセスとその子プロセスをすべて終了します。生成中の応答や未保存の状態が失われる可能性があります。', + 'agents.closeApp.confirm': 'App を終了', + 'agents.closeApp.success': 'ChatGPT App を終了しました({count} プロセス)。', + 'agents.closeApp.notRunning': 'ChatGPT App は実行されていません。', 'apiAccess.compatibleName': 'OpenAI 互換 {number}', 'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません', 'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 194334f..8c3ae15 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -390,7 +390,6 @@ export const zhCN = { 'agents.sessions.repairTitle': '恢复历史会话', 'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话', 'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话', - 'agents.sessions.autoRestoreDescription': '从本软件启动 Codex 时自动同步会话', 'agents.sessions.autoRepairEnabled': '已开启启动前自动恢复历史会话。', 'agents.sessions.autoRepairDisabled': '已关闭启动前自动恢复历史会话。', 'agents.sessions.settingLoading': '读取中', @@ -456,10 +455,8 @@ export const zhCN = { 'agents.clientVersion': '客户端版本', 'agents.notFetched': '未获取', 'agents.useModel': '使用模型', - 'agents.useModelDescription': '同步内核当前开放的模型,并将所选项设为默认模型', 'agents.pending': '尚未应用', 'agents.model.readingAvailable': '正在读取可用模型', - 'agents.model.cannotConfigure': '无可选模型,无法应用配置修改', 'agents.model.current': '当前已应用:{model}', 'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项', 'agents.modify.title': '配置修改', @@ -469,22 +466,22 @@ export const zhCN = { 'agents.modify.externalChanged': '检测到外部配置变化。软件会保留这些内容;点击“应用配置修改”后才会重新写入受管字段。', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', 'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。', - 'agents.modify.unconfigured': '选择模型后可应用配置修改;不会创建配置副本或备份。', - 'agents.footer.changed': '模型选择已变化,请先点击“应用配置修改”', + 'agents.footer.changed': '模型选择已变化;应用配置修改后将使用新模型。', 'agents.footer.applied': '配置修改已应用;软件只管理相关字段。', - 'agents.footer.externalChanged': '外部配置已保留。重新应用前无法启动该客户端。', + 'agents.footer.externalChanged': '外部配置已保留;应用配置修改后才会重新写入受管字段。', 'agents.footer.invalidManaged': '配置当前无效,请应用修改进行修复,或重置为默认配置。', 'agents.footer.unsupported': '当前系统无法配置该客户端', 'agents.footer.installFirst': '请先安装客户端并重新检测', 'agents.footer.noCommand': '检测到配置文件,但没有找到客户端命令', 'agents.footer.invalidConfig': '原配置格式异常,无法安全修改', - 'agents.footer.enableFirst': '请先点击“应用配置修改”,确保 CPA 模型配置生效后再启动客户端', 'agents.launchMethods': 'Codex 启动方式', 'agents.launch.enableFirst': '请先应用配置修改', 'agents.launch.applyFirst': '请先应用当前模型选择', 'agents.launch.starting': '启动中', 'agents.launch.start': '启动 {target}', 'agents.launch.unavailable': '无法启动', + 'agents.launch.closeChatgpt': '关闭 ChatGPT App', + 'agents.launch.closingChatgpt': '关闭中', 'agents.default.title': '重置为默认配置', 'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。', 'agents.default.confirm': '确认写入默认配置', @@ -492,6 +489,11 @@ export const zhCN = { 'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml,并重置软件记录的应用状态。该操作没有备份且无法恢复。', 'agents.clear.confirm': '确认清空配置', 'agents.clear.success': 'Codex 配置已清空。', + 'agents.closeApp.title': '关闭 ChatGPT App', + 'agents.closeApp.description': '这会关闭本机所有正在运行的 ChatGPT App 进程及其子进程。正在生成的回复或尚未保存的状态可能丢失。', + 'agents.closeApp.confirm': '确认关闭 App', + 'agents.closeApp.success': 'ChatGPT App 已关闭({count} 个进程)。', + 'agents.closeApp.notRunning': 'ChatGPT App 当前没有运行。', 'apiAccess.compatibleName': 'OpenAI 兼容 {number}', 'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号', 'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效', @@ -1174,7 +1176,6 @@ export const en: Record = { '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', @@ -1240,10 +1241,8 @@ export const en: Record = { 'agents.clientVersion': 'Client Version', 'agents.notFetched': 'Not available', 'agents.useModel': 'Model', - 'agents.useModelDescription': 'Synchronize models currently exposed by the core and set the selection as the default', 'agents.pending': 'Not Applied', 'agents.model.readingAvailable': 'Loading available models', - 'agents.model.cannotConfigure': 'No models are available, so configuration changes cannot be applied', 'agents.model.current': 'Currently applied: {model}', 'agents.model.firstSelection': '{count} models available; the first is selected by default', 'agents.modify.title': 'Configuration Changes', @@ -1253,22 +1252,22 @@ export const en: Record = { 'agents.modify.externalChanged': 'External configuration changes were detected and preserved. Managed fields are rewritten only after you click Apply Configuration Changes.', 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', 'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.', - 'agents.modify.unconfigured': 'Select a model and apply the configuration. No configuration copy or backup is created.', - 'agents.footer.changed': 'The model selection changed. Click “Apply Configuration Changes” first.', + 'agents.footer.changed': 'The model selection changed. Apply the configuration to use the new model.', 'agents.footer.applied': 'Configuration changes are applied; only the relevant fields are managed by the app.', - 'agents.footer.externalChanged': 'External changes were preserved. Apply them before launching this client.', + 'agents.footer.externalChanged': 'External changes were preserved. Managed fields are rewritten when you apply the configuration.', 'agents.footer.invalidManaged': 'The configuration is invalid. Apply changes to repair it or reset it to the default configuration.', 'agents.footer.unsupported': 'This client cannot be configured on the current system', 'agents.footer.installFirst': 'Install the client, then detect again', 'agents.footer.noCommand': 'A configuration file was found, but no client command is available', 'agents.footer.invalidConfig': 'The original configuration is invalid and cannot be modified safely', - 'agents.footer.enableFirst': 'Click “Apply Configuration Changes” before launching so the CPA model configuration is active', 'agents.launchMethods': 'Codex launch methods', 'agents.launch.enableFirst': 'Apply the configuration changes first', 'agents.launch.applyFirst': 'Apply the selected model first', 'agents.launch.starting': 'Starting', 'agents.launch.start': 'Start {target}', 'agents.launch.unavailable': 'Cannot Start', + 'agents.launch.closeChatgpt': 'Close ChatGPT App', + 'agents.launch.closingChatgpt': 'Closing', 'agents.default.title': 'Reset to Default Configuration', 'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.', 'agents.default.confirm': 'Write Default Configuration', @@ -1276,6 +1275,11 @@ export const en: Record = { 'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory and resets the application state tracked by this app. No backup is created and the operation cannot be undone.', 'agents.clear.confirm': 'Clear Configuration', 'agents.clear.success': 'The Codex configuration has been cleared.', + 'agents.closeApp.title': 'Close ChatGPT App', + 'agents.closeApp.description': 'This closes all running ChatGPT App processes and their child processes on this computer. In-progress responses or unsaved state may be lost.', + 'agents.closeApp.confirm': 'Close App', + 'agents.closeApp.success': 'ChatGPT App was closed ({count} processes).', + 'agents.closeApp.notRunning': 'ChatGPT App is not running.', 'apiAccess.compatibleName': 'OpenAI Compatible {number}', 'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon', 'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 1344e11..498b24e 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -18,6 +18,7 @@ import { ChevronDown, LoaderCircle, Play, + Power, RefreshCw, Search, Sparkles, @@ -83,6 +84,11 @@ type AgentConfigActionResult = { conflictFiles: string[]; }; +type ChatGptCloseResult = { + wasRunning: boolean; + closedProcesses: number; +}; + type AgentDefinition = { id: AgentClientId; name: string; @@ -489,7 +495,7 @@ export function AgentsPage() { const [launchTargetByClient, setLaunchTargetByClient] = useState>>({}); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); - const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | null>(null); + const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); @@ -501,6 +507,9 @@ export function AgentsPage() { const [clearError, setClearError] = useState(''); const [clearNotice, setClearNotice] = useState(''); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [closeAppError, setCloseAppError] = useState(''); + const [closeAppNotice, setCloseAppNotice] = useState(''); + const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -582,6 +591,9 @@ export function AgentsPage() { setClearError(''); setClearNotice(''); setClearConfirmOpen(false); + setCloseAppError(''); + setCloseAppNotice(''); + setCloseAppConfirmOpen(false); }, [selected]); useEffect(() => { @@ -624,10 +636,41 @@ export function AgentsPage() { const canLaunch = Boolean( activeStatus?.supportedPlatform && activeStatus.installed - && activeStatus.modificationEnabled - && activeStatus.modificationState === 'applied' - && selectedLaunchTarget, + && selectedLaunchTarget + && (selected === 'codex' + || (activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')), ); + const modelHint = modelSelectionError + || modelError + || (modelLoading + ? t('agents.model.readingAvailable') + : models.length === 0 + ? '' + : activeStatus?.modificationState === 'applied' + ? t('agents.model.current', { model: appliedModel || '—' }) + : t('agents.model.firstSelection', { count: models.length })); + const modificationDescription = activeStatus?.modificationState === 'external-changed' + ? t('agents.modify.externalChanged') + : activeStatus?.modificationState === 'invalid' + ? t('agents.modify.invalid') + : activeStatus?.modificationState === 'applied' + ? t('agents.modify.applied') + : ''; + const footerMessage = activeStatus?.modificationState === 'applied' + ? draftChanged + ? t('agents.footer.changed') + : t('agents.footer.applied') + : activeStatus?.modificationState === 'external-changed' + ? t('agents.footer.externalChanged') + : activeStatus?.modificationState === 'invalid' + ? t('agents.footer.invalidManaged') + : !activeStatus?.supportedPlatform + ? t('agents.footer.unsupported') + : !activeStatus.installed + ? t('agents.footer.installFirst') + : activeStatus.launchTargets.length === 0 + ? t('agents.footer.noCommand') + : ''; const refreshModels = () => { void loadModels(); @@ -728,7 +771,7 @@ export function AgentsPage() { setBusyAction('launch'); setLaunchError(''); try { - if (draftChanged) { + if (selected !== 'codex' && draftChanged) { throw new Error(t('agents.error.applyFirst')); } await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id }); @@ -739,6 +782,23 @@ export function AgentsPage() { } }; + const closeChatGptApp = async () => { + setBusyAction('close-app'); + setCloseAppError(''); + setCloseAppNotice(''); + try { + const result = await invoke('close_chatgpt_app'); + setCloseAppConfirmOpen(false); + setCloseAppNotice(result.wasRunning + ? t('agents.closeApp.success', { count: result.closedProcesses }) + : t('agents.closeApp.notRunning')); + } catch (requestError) { + setCloseAppError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const openDefaultConfirmation = () => { setDefaultError(''); setDefaultConfirmOpen(true); @@ -759,6 +819,16 @@ export function AgentsPage() { setClearConfirmOpen(false); }; + const openCloseAppConfirmation = () => { + setCloseAppError(''); + setCloseAppConfirmOpen(true); + }; + + const closeCloseAppConfirmation = () => { + setCloseAppError(''); + setCloseAppConfirmOpen(false); + }; + const availableSubpages = agentSubpages.filter( (subpage) => !subpage.clients || subpage.clients.includes(selected), ); @@ -864,7 +934,7 @@ export function AgentsPage() {
-
{t('agents.useModel')}{t('agents.useModelDescription')}
+
{t('agents.useModel')}
{draftChanged ? {t('agents.pending')} : null}
- - {modelSelectionError - || modelError - || (modelLoading - ? t('agents.model.readingAvailable') - : models.length === 0 - ? t('agents.model.cannotConfigure') - : activeStatus?.modificationState === 'applied' - ? t('agents.model.current', { model: appliedModel || '—' }) - : t('agents.model.firstSelection', { count: models.length }))} - + {modelHint ? ( + + {modelHint} + + ) : null}
{t('agents.modify.title')} - {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')} + {modificationDescription ? {modificationDescription} : null}
@@ -957,24 +1015,12 @@ export function AgentsPage() { {selected === 'codex' ? : null}
-
- {activeStatus?.modificationState === 'applied' ? : } - {activeStatus?.modificationState === 'applied' - ? draftChanged - ? t('agents.footer.changed') - : t('agents.footer.applied') - : activeStatus?.modificationState === 'external-changed' - ? t('agents.footer.externalChanged') - : activeStatus?.modificationState === 'invalid' - ? t('agents.footer.invalidManaged') - : !activeStatus?.supportedPlatform - ? t('agents.footer.unsupported') - : !activeStatus.installed - ? t('agents.footer.installFirst') - : activeStatus.launchTargets.length === 0 - ? t('agents.footer.noCommand') - : t('agents.footer.enableFirst')} -
+ {footerMessage ? ( +
+ {activeStatus?.modificationState === 'applied' ? : } + {footerMessage} +
+ ) : null}
{activeLaunchTargets.length > 1 ? ( @@ -1003,23 +1049,41 @@ export function AgentsPage() { disabled={ busy || !canLaunch - || draftChanged + || (selected !== 'codex' && draftChanged) } - title={draftChanged - ? t('agents.launch.applyFirst') - : activeStatus?.modificationState === 'applied' - ? selectedLaunchTarget?.detail - : t('agents.launch.enableFirst')} + title={selected === 'codex' + ? selectedLaunchTarget?.detail + : draftChanged + ? t('agents.launch.applyFirst') + : activeStatus?.modificationState === 'applied' + ? selectedLaunchTarget?.detail + : t('agents.launch.enableFirst')} > {busyAction === 'launch' ? : } {busyAction === 'launch' ? t('agents.launch.starting') : selectedLaunchTarget ? t('agents.launch.start', { target: selectedLaunchTarget.label }) : t('agents.launch.unavailable')} + {selected === 'codex' ? ( + + ) : null}
{launchError ? ( {launchError} ) : null} + {closeAppNotice ? ( + + {closeAppNotice} + + ) : null}
@@ -1085,6 +1149,29 @@ export function AgentsPage() {
) : null} + + {closeAppConfirmOpen ? ( +
+
+
+

{t('agents.closeApp.title')}

+
+

{t('agents.closeApp.description')}

+ {closeAppError ? ( + + {closeAppError} + + ) : null} +
+ + +
+
+
+ ) : null} ); } diff --git a/src/pages/CodexSessionAutoRestoreCard.tsx b/src/pages/CodexSessionAutoRestoreCard.tsx index 2bb85be..941def0 100644 --- a/src/pages/CodexSessionAutoRestoreCard.tsx +++ b/src/pages/CodexSessionAutoRestoreCard.tsx @@ -77,7 +77,6 @@ export function CodexSessionAutoRestoreCard() {
{t('agents.sessions.autoRepair')} - {t('agents.sessions.autoRestoreDescription')}
+ {selected === 'codex' ? ( + + ) : null}