diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 9073438..83dbe35 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -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>> = { + 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(readSelectedAgentClient); - const [activeSubpage, setActiveSubpage] = useState(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) => { + 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([]); const [models, setModels] = useState([]); const [modelByClient, setModelByClient] = useState>>( @@ -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} > @@ -1884,7 +1919,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
diff --git a/src/pages/CodexSessionsPanel.tsx b/src/pages/CodexSessionsPanel.tsx index 82fb472..62b34b5 100644 --- a/src/pages/CodexSessionsPanel.tsx +++ b/src/pages/CodexSessionsPanel.tsx @@ -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(), +}; + 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(null); - const [selectionMode, setSelectionMode] = useState(false); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [selectionMode, setSelectionMode] = useState(() => sessionViewCache.selectionMode); + const [selectedIds, setSelectedIds] = useState(() => new Set(sessionViewCache.selectedIds)); const [deleteConfirmation, setDeleteConfirmation] = useState(null); const [cleanupPreview, setCleanupPreview] = useState(null); const [cleanupSelectedIds, setCleanupSelectedIds] = useState>(() => 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('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)} > {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)))} > {t('agents.sessions.pageNumber', { page: currentPage })}
diff --git a/tests/agent-navigation-ui.cjs b/tests/agent-navigation-ui.cjs new file mode 100644 index 0000000..2aa7eb0 --- /dev/null +++ b/tests/agent-navigation-ui.cjs @@ -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); }); diff --git a/tests/agent-pending-ui.cjs b/tests/agent-pending-ui.cjs index 19404ff..f3eef3b 100644 --- a/tests/agent-pending-ui.cjs +++ b/tests/agent-pending-ui.cjs @@ -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); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index fd2d62c..1dff4a8 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -15,12 +15,24 @@ let currentOauth=false; const currentMappings:Record={}; 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'&¤tOauth,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(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({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(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({document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/>); -(window as any).fixtureRemount=()=>{root.unmount();root=createRoot(document.getElementById('root')!);render();}; +const render=()=>root.render({document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/>); +(window as any).fixtureRemount=(nextEmbedded=embedded)=>{embedded=nextEmbedded;root.unmount();root=createRoot(document.getElementById('root')!);render();}; render();