feat(agents): preserve page state across agent navigation

- Remember tabs, help expansion, and feedback per agent and view mode
- Restore Codex session pagination and selections with safe data reloads
- Add UI regression coverage for navigation, remounts, and draft retention
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent db4ac1dfe4
commit 6d7f3c8480
5 files changed
+277 -31

No files matched your search

+52 -17
View File
@@ -320,6 +320,31 @@ const agentSubpages: AgentSubpageDefinition[] = [
const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core';
type AgentViewState = {
subpage: AgentSubpageId;
connectionHelpOpen: boolean;
configurationError: string;
configurationNotice: string;
clearNotice: string;
launchError: string;
};
const DEFAULT_AGENT_VIEW_STATE: AgentViewState = {
subpage: DEFAULT_AGENT_SUBPAGE,
connectionHelpOpen: false,
configurationError: '',
configurationNotice: '',
clearNotice: '',
launchError: '',
};
// Keep navigation and feedback for this app session, including when the page unmounts.
// The compact view has no session manager, so it keeps its own navigation history.
let agentViewStateCache: Record<'full' | 'embedded', Partial<Record<AgentClientId, AgentViewState>>> = {
full: {},
embedded: {},
};
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1';
@@ -670,7 +695,30 @@ type AgentsPageProps = {
export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsPageProps = {}) {
const { t } = useI18n();
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
const [activeSubpage, setActiveSubpage] = useState<AgentSubpageId>(DEFAULT_AGENT_SUBPAGE);
const [viewStateByClient, setViewStateByClient] = useState(() => agentViewStateCache);
const viewMode = embedded ? 'embedded' : 'full';
const viewState = viewStateByClient[viewMode][selected] ?? DEFAULT_AGENT_VIEW_STATE;
const activeSubpage = viewState.subpage === 'sessions' && (embedded || selected !== 'codex')
? DEFAULT_AGENT_SUBPAGE : viewState.subpage;
const updateViewState = (patch: Partial<AgentViewState>) => {
setViewStateByClient((current) => {
const next = {
...current,
[viewMode]: {
...current[viewMode],
[selected]: { ...(current[viewMode][selected] ?? DEFAULT_AGENT_VIEW_STATE), ...patch },
},
};
agentViewStateCache = next;
return next;
});
};
const setActiveSubpage = (subpage: AgentSubpageId) => updateViewState({ subpage });
const { connectionHelpOpen, configurationError, configurationNotice, clearNotice, launchError } = viewState;
const setConfigurationError = (configurationError: string) => updateViewState({ configurationError });
const setConfigurationNotice = (configurationNotice: string) => updateViewState({ configurationNotice });
const setClearNotice = (clearNotice: string) => updateViewState({ clearNotice });
const setLaunchError = (launchError: string) => updateViewState({ launchError });
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
@@ -697,17 +745,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [detectionError, setDetectionError] = useState('');
const [modelError, setModelError] = useState('');
const [modelSelectionError, setModelSelectionError] = useState('');
const [configurationError, setConfigurationError] = useState('');
const [configurationNotice, setConfigurationNotice] = useState('');
const [backupsOpen, setBackupsOpen] = useState(false);
const [connectionHelpOpen, setConnectionHelpOpen] = useState(false);
const [defaultError, setDefaultError] = useState('');
const [templatePreview, setTemplatePreview] = useState<{ revision: string; files: string[] } | null>(null);
const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false);
const [clearError, setClearError] = useState('');
const [clearNotice, setClearNotice] = useState('');
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [launchError, setLaunchError] = useState('');
const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false);
const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false);
const [launchDirectory, setLaunchDirectory] = useState('');
@@ -868,18 +911,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}, [selected]);
useEffect(() => {
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
setModelSelectionError('');
setConfigurationError('');
setConfigurationNotice('');
setBackupsOpen(false);
setConnectionHelpOpen(false);
setDefaultError('');
setDefaultConfirmOpen(false);
setClearError('');
setClearNotice('');
setClearConfirmOpen(false);
setLaunchError('');
setCodexCatalogDialogOpen(false);
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
setLaunchDirectoryError('');
@@ -1690,10 +1728,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
type="button"
className={selected === agent.id ? 'active' : ''}
key={agent.id}
onClick={() => {
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
setSelected(agent.id);
}}
onClick={() => setSelected(agent.id)}
disabled={busy}
>
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
@@ -1884,7 +1919,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="agent-auth-method">
<div className="agent-signin-label"><label htmlFor="agent-connection-method">{t('agents.modify.authMethod')}</label>
<button type="button" className="agent-signin-help-toggle" aria-expanded={connectionHelpOpen} aria-controls="agent-signin-hint"
onClick={() => setConnectionHelpOpen((open) => !open)}>
onClick={() => updateViewState({ connectionHelpOpen: !connectionHelpOpen })}>
{t(connectionHelpOpen ? 'agents.modify.authHelpHide' : 'agents.modify.authHelpShow')}
<ChevronDown size={14} aria-hidden="true" />
</button>
+46 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
@@ -41,6 +41,13 @@ type DeleteConfirmation = {
const PAGE_SIZE = 50;
// Retain the user's place across agent/tab navigation; always reload the actual sessions.
let sessionViewCache = {
offset: 0,
selectionMode: false,
selectedIds: new Set<string>(),
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -51,8 +58,8 @@ export function CodexSessionsPanel() {
const [loading, setLoading] = useState(true);
const [operation, setOperation] = useState<'delete' | 'repair' | 'preview' | 'cleanup' | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [selectionMode, setSelectionMode] = useState(() => sessionViewCache.selectionMode);
const [selectedIds, setSelectedIds] = useState(() => new Set(sessionViewCache.selectedIds));
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation | null>(null);
const [cleanupPreview, setCleanupPreview] = useState<SessionIndexCleanupPreview | null>(null);
const [cleanupSelectedIds, setCleanupSelectedIds] = useState<Set<string>>(() => new Set());
@@ -62,13 +69,38 @@ export function CodexSessionsPanel() {
processed: 0,
total: 0,
});
const mountedRef = useRef(false);
const loadRequestRef = useRef(0);
const initialOffsetRef = useRef(sessionViewCache.offset);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
loadRequestRef.current += 1;
};
}, []);
useEffect(() => {
sessionViewCache = {
offset: page?.offset ?? initialOffsetRef.current,
selectionMode,
selectedIds: new Set(selectedIds),
};
}, [page, selectionMode, selectedIds]);
const loadPage = useCallback(async (offset = 0, silent = false) => {
if (!silent) setLoading(true);
if (!mountedRef.current) return null;
const requestId = ++loadRequestRef.current;
if (!silent) {
setLoading(true);
setNotice((current) => current?.kind === 'error' ? null : current);
}
try {
const result = await invoke<CodexSessionPage>('list_codex_sessions', {
request: { offset, limit: PAGE_SIZE },
});
if (!mountedRef.current || requestId !== loadRequestRef.current) return null;
if (result.sessions.length === 0 && result.offset > 0) {
return await loadPage(Math.max(0, result.offset - result.limit), silent);
}
@@ -79,15 +111,16 @@ export function CodexSessionsPanel() {
}
return result;
} catch (error) {
if (!mountedRef.current || requestId !== loadRequestRef.current) return null;
setNotice({ kind: 'error', message: t('agents.sessions.loadFailed', { error: errorMessage(error) }) });
return null;
} finally {
if (!silent) setLoading(false);
if (!silent && mountedRef.current && requestId === loadRequestRef.current) setLoading(false);
}
}, [t]);
useEffect(() => {
void loadPage();
void loadPage(sessionViewCache.offset);
}, [loadPage]);
useEffect(() => {
@@ -107,7 +140,8 @@ export function CodexSessionsPanel() {
const sessions = page?.sessions ?? [];
const pageCounts = codexSessionPageCounts(sessions);
const currentPage = Math.floor((page?.offset ?? 0) / (page?.limit ?? PAGE_SIZE)) + 1;
const currentOffset = page?.offset ?? initialOffsetRef.current;
const currentPage = Math.floor(currentOffset / (page?.limit ?? PAGE_SIZE)) + 1;
const selectedSessions = useMemo(
() => sessions.filter((session) => selectedIds.has(session.id)),
[selectedIds, sessions],
@@ -172,7 +206,7 @@ export function CodexSessionsPanel() {
}),
});
setSelectedIds(new Set());
await loadPage(page?.offset ?? 0, true);
await loadPage(currentOffset, true);
} catch (error) {
setNotice({ kind: 'error', message: t('agents.sessions.deleteFailed', { error: errorMessage(error) }) });
} finally {
@@ -201,7 +235,7 @@ export function CodexSessionsPanel() {
kind: result.encryptedContentWarning || result.warnings.length > 0 ? 'warning' : 'success',
message: messages.join(' '),
});
await loadPage(page?.offset ?? 0, true);
await loadPage(currentOffset, true);
} catch (error) {
setNotice({ kind: 'error', message: t('agents.sessions.repairFailed', { error: errorMessage(error) }) });
} finally {
@@ -279,7 +313,7 @@ export function CodexSessionsPanel() {
type="button"
className="secondary-button compact-button"
disabled={busy || loading}
onClick={() => void loadPage(page?.offset ?? 0)}
onClick={() => void loadPage(currentOffset)}
>
<RefreshCw size={15} className={loading ? 'spin' : ''} />
{t('agents.sessions.refresh')}
@@ -439,7 +473,7 @@ export function CodexSessionsPanel() {
aria-label={t('agents.sessions.previousPage')}
title={t('agents.sessions.previousPage')}
disabled={busy || loading || !page || page.offset === 0}
onClick={() => void loadPage(Math.max(0, (page?.offset ?? 0) - (page?.limit ?? PAGE_SIZE)))}
onClick={() => void loadPage(Math.max(0, currentOffset - (page?.limit ?? PAGE_SIZE)))}
><ArrowLeft size={15} /></button>
<span>{t('agents.sessions.pageNumber', { page: currentPage })}</span>
<button
@@ -448,7 +482,7 @@ export function CodexSessionsPanel() {
aria-label={t('agents.sessions.nextPage')}
title={t('agents.sessions.nextPage')}
disabled={busy || loading || !page?.hasMore}
onClick={() => void loadPage((page?.offset ?? 0) + (page?.limit ?? PAGE_SIZE))}
onClick={() => void loadPage(currentOffset + (page?.limit ?? PAGE_SIZE))}
><ArrowRight size={15} /></button>
</div>
</section>