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:
1 parent
db4ac1dfe4
commit
6d7f3c8480
5 files changed
+277
-31
No files matched your search
+52
-17
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user