diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 280e945a90..4fa897751e 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -579,3 +579,81 @@ test('runner facts refresh without resetting unsaved controls, and a failed read expect(input).toHaveValue('unsaved-draft'); } finally { view.unmount(); jest.useRealTimers(); } }); + +test('the key list re-reads while visible, so an outside call updates Last used', async () => { + jest.useFakeTimers(); + let used = false; + const defaultGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url, options) => ( + url === '/api/credentials' + ? Promise.resolve({ data: [{ _id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', + lastUsedAt: used ? '2026-10-31T09:15:00Z' : null, revokedAt: null, status: 'active' }] }) + : defaultGet(url, options) + )); + const view = renderPage(); + try { + const before = await screen.findByText(/last used/i); + const beforeText = before.textContent; + used = true; + await act(async () => { jest.advanceTimersByTime(15_000); }); + expect(screen.getByText(/last used/i).textContent).not.toEqual(beforeText); + } finally { view.unmount(); jest.useRealTimers(); } +}); + +test('key polling stops on unmount', async () => { + jest.useFakeTimers(); + const view = renderPage(); + try { + await screen.findByText(/last used/i); + view.unmount(); + const calls = axios.get.mock.calls.filter(([url]) => url === '/api/credentials').length; + await act(async () => { jest.advanceTimersByTime(60_000); }); + expect(axios.get.mock.calls.filter(([url]) => url === '/api/credentials').length).toBe(calls); + } finally { jest.useRealTimers(); } +}); + +test('a revoke from outside moves the header off Connected without a remount', async () => { + jest.useFakeTimers(); + let state = 'connected'; + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); + if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{ + name: 'maya-claude', instanceId: 'default', runtime: { runtimeType: 'webhook' }, + connection: { state, lastSeenAt: null }, + }] } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, + } } }); + return Promise.resolve({ data: [] }); + }); + const view = renderPage(); + try { + await act(async () => { await Promise.resolve(); }); + expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull(); + state = 'revoked'; + await act(async () => { jest.advanceTimersByTime(30_000); }); + expect(await screen.findByRole('link', { name: 'Reconnect' })).toBeInTheDocument(); + } finally { view.unmount(); jest.useRealTimers(); } +}); + +test('key polling pauses while hidden and re-reads on focus', async () => { + jest.useFakeTimers(); + const view = renderPage(); + const keyReads = () => axios.get.mock.calls.filter(([url]) => url === '/api/credentials').length; + try { + await screen.findByText(/last used/i); + const hidden = jest.spyOn(document, 'hidden', 'get').mockReturnValue(true); + const before = keyReads(); + await act(async () => { jest.advanceTimersByTime(45_000); }); + expect(keyReads()).toBe(before); + hidden.mockReturnValue(false); + await act(async () => { window.dispatchEvent(new Event('focus')); }); + expect(keyReads()).toBe(before + 1); + hidden.mockRestore(); + } finally { view.unmount(); jest.useRealTimers(); } +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 068c98aa90..6c9f9ed47c 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -101,6 +101,35 @@ const V2AgentSettingsPage: React.FC = () => { return () => { active = false; }; }, [access, podId, fetchKeys, identityKey, t]); + // A key used or revoked from outside this page only shows up when the list + // is re-read. Poll while the tab is visible, and re-read on focus. + useEffect(() => { + if (!access || !podId) return undefined; + let active = true; + let pending = false; + const refreshKeys = async () => { + if (pending || document.hidden) return; + pending = true; + try { + const rows = await fetchKeys(); + if (active) setKeys(rows); + } catch { + // Keep the last list; the next tick or focus retries. + } finally { + pending = false; + } + }; + const timer = window.setInterval(refreshKeys, 15_000); + window.addEventListener('focus', refreshKeys); + document.addEventListener('visibilitychange', refreshKeys); + return () => { + active = false; + window.clearInterval(timer); + window.removeEventListener('focus', refreshKeys); + document.removeEventListener('visibilitychange', refreshKeys); + }; + }, [access, podId, fetchKeys]); + const replaceKey = async () => { if (!access || !podId || pendingAction !== 'replace' || busy || installation?.runtime?.runtimeType === 'wrapper') return; const requestIdentity = identityKey;