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>
+161
View File
@@ -0,0 +1,161 @@
// Run Vite on port 1421, then node tests/agent-navigation-ui.cjs.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
const tab = name => page.getByRole('tab', { name, exact: true });
const button = name => page.getByRole('button', { name, exact: true });
const client = name => page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
const active = async name => {
await page.waitForFunction(name => Array.from(document.querySelectorAll('[role=tab]'))
.some(el => el.textContent === name && el.getAttribute('aria-selected') === 'true'), name);
assert.equal(await page.locator('[role=tab][aria-selected=true]').count(), 1);
};
const ready = () => page.waitForFunction(() => {
const refresh = document.querySelector('.agent-header-actions button');
return refresh && !refresh.disabled && window.fixtureCalls.some(call => call.cmd === 'get_agent_models');
});
const open = async query => {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query);
await ready();
};
const remount = async embedded => {
await page.evaluate(embedded => window.fixtureRemount(embedded), embedded);
await ready();
};
const sessionsReady = () => page.locator('.codex-session-row').first().waitFor();
const sessionPage = async number => {
await sessionsReady();
assert.equal(await page.locator('.codex-session-pagination span').textContent(), `第 ${number} 页`);
};
// Every agent remembers its own tab, draft and feedback, in both entry points.
for (const width of [1280, 540]) for (const embedded of [false, true]) {
await page.setViewportSize({ width, height: 900 });
await open(embedded ? 'embedded' : '');
await page.locator('.agent-model-trigger').click();
await page.getByRole('option', { name: 'gpt-two' }).click();
if (!embedded) await button('如何选择').click();
await tab('配置管理').click();
await button('手动备份').click();
const notice = page.getByText('已手动备份当前磁盘配置,未包含未保存的表单修改。', { exact: true });
await notice.waitFor();
await client('OpenCode').click();
await active('基础配置');
assert.equal(await notice.count(), 0);
assert.equal(await tab('会话管理').count(), 0);
await tab('配置管理').click();
await client('Pi').click();
await active('基础配置');
await client('Codex').click();
await active('配置管理');
await notice.waitFor();
await page.getByText('待应用', { exact: true }).waitFor();
await client('Codex').click();
await active('配置管理');
await remount();
await active('配置管理');
await notice.waitFor();
await tab('配置管理').press('Home');
await active('基础配置');
assert.equal(await tab('基础配置').evaluate(el => el === document.activeElement), true);
assert.equal(await page.locator('.agent-model-trigger strong').textContent(), 'gpt-two');
if (!embedded) assert.equal(await page.locator('.agent-signin-help-toggle').getAttribute('aria-expanded'), 'true');
await client('OpenCode').click();
await active('配置管理');
await client('Codex').click();
await active('基础配置');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false);
}
await open('fail-apply');
await page.locator('.agent-model-trigger').click();
await page.getByRole('option', { name: 'gpt-two' }).click();
await button('更新配置').click();
await page.getByText(/模拟配置写入失败/).waitFor();
await client('OpenCode').click();
assert.equal(await page.getByText(/模拟配置写入失败/).count(), 0);
await client('Codex').click();
await page.getByText(/模拟配置写入失败/).waitFor();
await page.getByText('待应用', { exact: true }).waitFor();
// Full and compact navigation remain independent, including Codex-only sessions.
await open('');
await tab('会话管理').click();
await sessionsReady();
await button('下一页').click();
await sessionPage(2);
await button('多选').click();
const selectedSession = page.getByRole('checkbox', { name: '选择会话 session-51', exact: true });
await selectedSession.check();
await client('OpenCode').click();
await active('基础配置');
assert.equal(await tab('会话管理').count(), 0);
await client('Codex').click();
await active('会话管理');
await sessionPage(2);
assert.ok(await selectedSession.isChecked());
await remount(true);
await active('基础配置');
assert.equal(await tab('会话管理').count(), 0);
await tab('配置管理').click();
await remount(false);
await active('会话管理');
await sessionPage(2);
assert.ok(await selectedSession.isChecked());
await remount(true);
await active('配置管理');
await remount(false);
await sessionPage(2);
// A failed reload leaves the remembered page and selection available for a retry.
await tab('基础配置').click();
await page.evaluate(() => { window.fixtureFailSessionLoad = true; });
await tab('会话管理').click();
await page.getByText(/模拟会话读取失败/).waitFor();
assert.equal(await page.locator('.codex-session-pagination span').textContent(), '第 2 页');
await page.evaluate(() => { window.fixtureFailSessionLoad = false; });
await page.locator('.codex-sessions-page').getByRole('button', { name: '刷新', exact: true }).click();
await sessionPage(2);
assert.ok(await selectedSession.isChecked());
assert.equal(await page.getByText(/模拟会话读取失败/).count(), 0);
// Reload actual data on return: drop missing selections and back up from an empty page.
await tab('基础配置').click();
await page.evaluate(() => { window.fixtureSessionIds = window.fixtureSessionIds.filter(id => id !== 'session-51'); });
await tab('会话管理').click();
await sessionPage(2);
assert.equal(await page.locator('.codex-session-row input:checked').count(), 0);
assert.equal(await selectedSession.count(), 0);
await tab('配置管理').click();
await page.evaluate(() => { window.fixtureSessionIds = window.fixtureSessionIds.slice(0, 10); });
await tab('会话管理').click();
await sessionPage(1);
assert.equal(await page.locator('.codex-session-row').count(), 10);
// A response from the previous visit must not restore stale data or overwrite its cache.
await tab('基础配置').click();
await page.evaluate(() => { window.fixtureDeferSessionLoad = true; });
await tab('会话管理').click();
await page.waitForFunction(() => !!window.fixtureFinishSessionLoad);
await client('OpenCode').click();
await page.evaluate(() => { window.fixtureSessionIds = ['session-new']; });
await client('Codex').click();
await sessionsReady();
await page.evaluate(() => window.fixtureFinishSessionLoad());
await page.waitForFunction(() => document.querySelector('.codex-session-row strong')?.textContent === 'session-new');
assert.deepEqual(await page.locator('.codex-session-row strong').allTextContents(), ['session-new']);
await remount();
await sessionsReady();
assert.deepEqual(await page.locator('.codex-session-row strong').allTextContents(), ['session-new']);
assert.deepEqual(errors, []);
console.log('PASS: per-agent tabs, drafts, feedback, help, remounts, compact/full isolation, keyboard, narrow layouts, session pagination/selection and stale response handling');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
+4
View File
@@ -60,6 +60,10 @@ const assert = require('node:assert/strict');
}
await expectPending(true);
await page.evaluate(() => window.fixtureRemount());
if (!query.includes('client=pi')) {
assert.equal(await page.getByRole('tab', { name: '配置管理', exact: true }).getAttribute('aria-selected'), 'true');
}
await core();
await ready(); await expectPending(true);
await apply();
await selectModel(original); await expectPending(true);
+14 -2
View File
@@ -15,12 +15,24 @@ let currentOauth=false; const currentMappings:Record<string,any>={};
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
let appliedCount=0;
const calls:any[]=[];(window as any).fixtureCalls=calls;
let embedded=params.has('embedded');
(window as any).fixtureSessionIds=Array.from({length:61},(_,index)=>`session-${index+1}`);
mockIPC(async (cmd,args:any) => {
calls.push({cmd,args});
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}));
if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}];
if(cmd==='list_codex_sessions') {
const {offset,limit}=args.request;
const sessionIds:string[]=[...(window as any).fixtureSessionIds];
if((window as any).fixtureDeferSessionLoad) {
(window as any).fixtureDeferSessionLoad=false;
await new Promise<void>(resolve=>{(window as any).fixtureFinishSessionLoad=resolve;});
}
if((window as any).fixtureFailSessionLoad)throw new Error('模拟会话读取失败');
return {codexHome:'C:/test/.codex',databasePaths:['C:/test/.codex/state.sqlite'],totalCount:sessionIds.length,offset,limit,hasMore:offset+limit<sessionIds.length,warnings:[],sessions:sessionIds.slice(offset,offset+limit).map(id=>({id,title:id,cwd:'C:/test/project',modelProvider:'test',archived:false,updatedAtMs:null,databasePath:'C:/test/.codex/state.sqlite'}))};
}
if(cmd==='get_deepseek_harness_process_status') return harnessStatus;
if(cmd==='restart_agent_app'||cmd==='restart_deepseek_harness_process') {
if(params.has('defer-restart'))await new Promise<void>(resolve=>{(window as any).fixtureFinishRestart=resolve;});
@@ -56,6 +68,6 @@ mockIPC(async (cmd,args:any) => {
throw new Error('Unhandled fixture command: '+cmd);
});
let root=createRoot(document.getElementById('root')!);
const render=()=>root.render(<I18nProvider><AgentsPage embedded={params.has('embedded')} onConfigurationApplied={()=>{document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/></I18nProvider>);
(window as any).fixtureRemount=()=>{root.unmount();root=createRoot(document.getElementById('root')!);render();};
const render=()=>root.render(<I18nProvider><AgentsPage embedded={embedded} onConfigurationApplied={()=>{document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/></I18nProvider>);
(window as any).fixtureRemount=(nextEmbedded=embedded)=>{embedded=nextEmbedded;root.unmount();root=createRoot(document.getElementById('root')!);render();};
render();