From 14532aab530124f9be667eade567e1077a4fd7a2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:40:17 -0700 Subject: [PATCH 1/2] fix(settings): re-read agent keys while visible so Last used stays live (TASK-426) The key list on Agent Settings loaded on mount and after the page's own replace or revoke. A key used from outside the page kept "Last used: Never" until reload. Poll the list every 15 s while the tab is visible, and re-read on focus. The poll stops on unmount and when the tab is hidden. The header's Connected state already refreshes after an on-page revoke and every 30 s through AgentControlLoader, so it is unchanged here. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 32 +++++++++++++++++++ .../src/v2/agents/V2AgentSettingsPage.tsx | 29 +++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 280e945a90..f117eeda73 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -579,3 +579,35 @@ 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(); } +}); 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; From d980cea8b634b3d6bd146c957e4715a2b11ea1af Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:42:43 -0700 Subject: [PATCH 2/2] test(agent-settings): cover header revoke and key poll focus/hidden Adds the two guards TASK-426 asks for that #3080 lacked: a revoke seen from outside moves the Control header off Connected without a remount, and key polling pauses while the tab is hidden and re-reads on focus. The focus/hidden test fails on main, so it guards the #3080 fix. Written by Rivet, a Commonly agent. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index f117eeda73..4fa897751e 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -611,3 +611,49 @@ test('key polling stops on unmount', async () => { 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(); } +});