diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.pollRecovery.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.pollRecovery.test.tsx
new file mode 100644
index 000000000..23dcd1143
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2PodsSidebar.pollRecovery.test.tsx
@@ -0,0 +1,99 @@
+// @ts-nocheck
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { i18nReady } from '../../i18n';
+import V2PodsSidebar from '../components/V2PodsSidebar';
+
+// TASK-184, the recovery cell. This file mocks the API LAYER, not the hook, so
+// the real useV2Pods and the real sidebar are exercised end to end: a first
+// load that fails must not leave a tab that polls successfully every minute
+// showing an error and no rows forever.
+
+const NOW = Date.parse('2026-09-28T05:00:00.000Z');
+const mockGet = jest.fn();
+// Stable identity: a fresh object per render would change `refresh`'s dependency
+// and re-run the mount effect forever (the real hook memoizes this).
+const mockApi = { get: mockGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() };
+
+jest.mock('../hooks/useV2Api', () => ({
+ useV2Api: () => mockApi,
+}));
+
+jest.mock('../hooks/useV2Pinned', () => ({
+ useV2Pinned: () => ({ pinned: new Set(), toggle: jest.fn(), isPinned: () => false }),
+}));
+
+jest.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }),
+}));
+
+// The real hook fetches on mount, so the first paint is `loading: true` and the
+// settled state arrives a few microtasks later; fake timers do not advance
+// microtasks, so drain them explicitly instead of waiting on a timer.
+const settle = async () => {
+ await act(async () => { await Promise.resolve(); });
+};
+
+const podRow = [{
+ _id: 'p1',
+ name: 'Sharpen',
+ type: 'team',
+ members: [{ _id: 'me', username: 'me', isBot: false }],
+ lastMessage: {
+ content: 'x',
+ createdAt: new Date(NOW - 2 * 3600 * 1000).toISOString(),
+ username: 'a',
+ },
+}];
+
+describe('V2PodsSidebar — a failed mount recovers on the next poll', () => {
+ beforeAll(async () => { await i18nReady; });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers({ now: NOW });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('shows the rows again once a poll succeeds, without a reload', async () => {
+ mockGet.mockRejectedValueOnce(new Error('network down'));
+ render(
+
+
+ ,
+ );
+
+ await settle();
+ expect(screen.getByText('network down')).toBeInTheDocument();
+ expect(screen.queryByText('Sharpen')).not.toBeInTheDocument();
+
+ mockGet.mockResolvedValueOnce(podRow);
+ await act(async () => { jest.advanceTimersByTime(60 * 1000); });
+
+ expect(mockGet).toHaveBeenCalledWith('/api/pods');
+ expect(screen.getByText('Sharpen')).toBeInTheDocument();
+ expect(screen.queryByText('network down')).not.toBeInTheDocument();
+ });
+
+ it('keeps the error while the poll keeps failing', async () => {
+ mockGet.mockRejectedValueOnce(new Error('network down'));
+ render(
+
+
+ ,
+ );
+ await settle();
+ expect(screen.getByText('network down')).toBeInTheDocument();
+
+ mockGet.mockRejectedValue(new Error('still down'));
+ await act(async () => { jest.advanceTimersByTime(3 * 60 * 1000); });
+ await settle();
+
+ expect(screen.getByText('network down')).toBeInTheDocument();
+ expect(screen.queryByText('Sharpen')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
index 93ef54ebd..a8fc75114 100644
--- a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
+++ b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
@@ -86,6 +86,7 @@ const renderSidebar = (pods, selectedPodId = 'sharpen', extra = {}) => render(
attentionCountByPod={{ sharpen: 2, connectors: 1, naming: 0, hq: 91 }}
podsState={{
pods, loading: false, error: null, createPod: mockCreatePod, patchLastMessage: jest.fn(),
+ refresh: jest.fn(() => Promise.resolve()),
}}
{...extra}
/>
diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.timestampFreshness.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.timestampFreshness.test.tsx
new file mode 100644
index 000000000..630687be4
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2PodsSidebar.timestampFreshness.test.tsx
@@ -0,0 +1,124 @@
+// @ts-nocheck
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { i18nReady } from '../../i18n';
+import V2PodsSidebar from '../components/V2PodsSidebar';
+
+// TASK-184. The row time is a DATUM from the last /api/pods, and the minute
+// tick only recomputes the label — so a tab left open kept ageing a frozen
+// timestamp and read further from the truth the longer it stayed open. This
+// test drives the surface: the label moves when the datum does, and does not
+// move while the tab is hidden.
+
+const NOW = Date.parse('2026-09-28T05:00:00.000Z');
+const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
+
+const row = (createdAt: string) => ([{
+ _id: 'p1',
+ name: 'Sharpen',
+ type: 'team',
+ members: [{ _id: 'me', username: 'me', isBot: false }],
+ lastMessage: { content: 'x', createdAt, username: 'a' },
+}]);
+
+const mockRefresh = jest.fn();
+let mockSetPods = () => {};
+// `jest.mock` factories may only close over `mock`-prefixed names.
+const mockInitialPods = row(new Date(NOW - 2 * 3600 * 1000).toISOString());
+
+jest.mock('../hooks/useV2Pods', () => ({
+ useV2Pods: () => {
+ const { useState } = require('react');
+ const [pods, setPods] = useState(() => mockInitialPods);
+ mockSetPods = setPods;
+ return {
+ pods,
+ loading: false,
+ error: null,
+ createPod: jest.fn(),
+ patchLastMessage: jest.fn(),
+ refresh: mockRefresh,
+ };
+ },
+}));
+
+jest.mock('../hooks/useV2Api', () => ({
+ useV2Api: () => ({ get: jest.fn(() => Promise.resolve([])), post: jest.fn(), patch: jest.fn(), del: jest.fn() }),
+}));
+
+jest.mock('../hooks/useV2Pinned', () => ({
+ useV2Pinned: () => ({ pinned: new Set(), toggle: jest.fn(), isPinned: () => false }),
+}));
+
+jest.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }),
+}));
+
+let visibility = 'visible';
+
+const renderSidebar = () => render(
+
+
+ ,
+);
+
+describe('V2PodsSidebar — row time follows the datum', () => {
+ beforeAll(async () => { await i18nReady; });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers({ now: NOW });
+ visibility = 'visible';
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => visibility,
+ });
+ // The poll re-reads the row times; the surface must show the new datum.
+ mockRefresh.mockImplementation(() => {
+ mockSetPods(row(iso(0)));
+ return Promise.resolve();
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('re-reads the datum on the minute tick and re-renders the label', async () => {
+ renderSidebar();
+ expect(await screen.findByText('2h')).toBeInTheDocument();
+
+ await act(async () => { jest.advanceTimersByTime(60 * 1000); });
+
+ expect(mockRefresh).toHaveBeenCalledWith({ silent: true });
+ // NOW + the tick's own minute = 1 minute after the refreshed datum.
+ expect(await screen.findByText('1m')).toBeInTheDocument();
+ expect(screen.queryByText('2h')).not.toBeInTheDocument();
+ });
+
+ it('does not poll a hidden tab, and re-reads the moment it becomes visible', async () => {
+ renderSidebar();
+ expect(await screen.findByText('2h')).toBeInTheDocument();
+
+ visibility = 'hidden';
+ await act(async () => { jest.advanceTimersByTime(10 * 60 * 1000); });
+ expect(mockRefresh).not.toHaveBeenCalled();
+ expect(screen.getByText('2h')).toBeInTheDocument();
+
+ visibility = 'visible';
+ await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
+ expect(mockRefresh).toHaveBeenCalledWith({ silent: true });
+ });
+
+ it('the tick is a minute, not a render', async () => {
+ renderSidebar();
+ await screen.findByText('2h');
+
+ await act(async () => { jest.advanceTimersByTime(59 * 1000); });
+ expect(mockRefresh).not.toHaveBeenCalled();
+
+ await act(async () => { jest.advanceTimersByTime(1 * 1000); });
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/v2/__tests__/useV2PodsSilentRefresh.test.tsx b/frontend/src/v2/__tests__/useV2PodsSilentRefresh.test.tsx
new file mode 100644
index 000000000..406ffc502
--- /dev/null
+++ b/frontend/src/v2/__tests__/useV2PodsSilentRefresh.test.tsx
@@ -0,0 +1,127 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { useV2Pods } from '../hooks/useV2Pods';
+
+const mockApi = {
+ get: jest.fn(),
+ post: jest.fn(),
+ patch: jest.fn(),
+ del: jest.fn(),
+};
+
+jest.mock('../hooks/useV2Api', () => ({
+ useV2Api: () => mockApi,
+}));
+
+const row = (createdAt: string) => ({
+ _id: 'p1',
+ name: 'Sharpen',
+ lastMessage: { content: 'x', createdAt, username: 'a' },
+});
+
+// TASK-184. A poll that re-reads the sidebar's row times must not be
+// observable as a state change: `loading` blanks the list into a spinner
+// (V2PodsSidebar renders `v2-spinner` while loading), so a non-silent refresh
+// on the minute tick would blink the whole sidebar once a minute, and a single
+// failed poll would replace the last good rows with an error. Both arms are
+// asserted here — silent, and the loud default that proves silent differs.
+describe('useV2Pods silent refresh', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('re-reads the list in place without entering the loading state', async () => {
+ mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
+ const { result } = renderHook(() => useV2Pods());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ let release: (value: unknown) => void = () => {};
+ mockApi.get.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; }));
+ let pending: Promise = Promise.resolve();
+ await act(async () => {
+ pending = result.current.refresh({ silent: true });
+ });
+
+ // In flight: the loud path is `true` here (next test), so this is the
+ // observable difference the option buys.
+ expect(result.current.loading).toBe(false);
+
+ await act(async () => {
+ release([row('2026-09-28T05:00:00.000Z')]);
+ await pending;
+ });
+ expect(result.current.loading).toBe(false);
+ expect(result.current.pods[0].lastMessage?.createdAt).toBe('2026-09-28T05:00:00.000Z');
+ });
+
+ it('keeps the last good rows and the last error when a silent read fails', async () => {
+ mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
+ const { result } = renderHook(() => useV2Pods());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ mockApi.get.mockRejectedValueOnce(new Error('network down'));
+ await act(async () => {
+ await result.current.refresh({ silent: true });
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.pods).toHaveLength(1);
+ expect(result.current.pods[0].lastMessage?.createdAt).toBe('2026-09-28T04:00:00.000Z');
+ });
+
+ it('the loud default does enter the loading state and does surface a failure', async () => {
+ mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
+ const { result } = renderHook(() => useV2Pods());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ let release: (value: unknown) => void = () => {};
+ mockApi.get.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; }));
+ let pending: Promise = Promise.resolve();
+ await act(async () => {
+ pending = result.current.refresh();
+ });
+ expect(result.current.loading).toBe(true);
+ await act(async () => {
+ release([row('2026-09-28T05:00:00.000Z')]);
+ await pending;
+ });
+ expect(result.current.loading).toBe(false);
+
+ mockApi.get.mockRejectedValueOnce(new Error('network down'));
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.error).toBe('network down');
+ });
+ it('a successful poll clears a stale error instead of refreshing data nobody can see', async () => {
+ // The cell the first draft missed: the mount FAILED, so `error` is set and
+ // V2PodsSidebar (:501/:502) renders the error INSTEAD of the rows. A poll
+ // that swaps the data but cannot clear the error leaves the view broken
+ // while fetching correctly every minute.
+ mockApi.get.mockRejectedValueOnce(new Error('network down'));
+ const { result } = renderHook(() => useV2Pods());
+ await waitFor(() => expect(result.current.error).toBe('network down'));
+ expect(result.current.pods).toHaveLength(0);
+
+ mockApi.get.mockResolvedValueOnce([row('2026-09-28T05:00:00.000Z')]);
+ await act(async () => {
+ await result.current.refresh({ silent: true });
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.pods).toHaveLength(1);
+ });
+
+ it('a silent failure still never clears an error it did not earn', async () => {
+ // The complement: only a SUCCESS is evidence the error is stale.
+ mockApi.get.mockRejectedValueOnce(new Error('network down'));
+ const { result } = renderHook(() => useV2Pods());
+ await waitFor(() => expect(result.current.error).toBe('network down'));
+
+ mockApi.get.mockRejectedValueOnce(new Error('still down'));
+ await act(async () => {
+ await result.current.refresh({ silent: true });
+ });
+
+ expect(result.current.error).toBe('network down');
+ });
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 4ff57469b..0135624e0 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -1157,6 +1157,33 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(thread).toContain('delete el.dataset.historyAnchor');
});
+ test('the pod list opts out of native anchoring, because the minute poll re-sorts it under the reader', () => {
+ // ux-lead gate on #1989: with Everything open and scrolled, a message to a
+ // pod in view moves that row to the top, native anchoring drags the list
+ // ~14 rows and 4 of 17 rows stayed in view. Opting the scroller out holds
+ // 16 of 17. Same property the chat transcript uses, for the opposite
+ // reason — see the note on the declaration in v2.css.
+ const list = lastRuleBody(v2, '.v2-pods__list');
+ // The rule being reset must still exist, or a green here means nothing.
+ expect(list).toContain('overflow-y: auto');
+ expect(list).toContain('overflow-anchor: none');
+ expect(podsSidebar).toContain('className="v2-pods__list"');
+ // `.v2-pods__list` is declared twice (from #251 and #1578 "sidebar at
+ // scale"), both top-level, so the LATER block wins every conflicting
+ // declaration and is the effective rule — hence `lastRuleBody` here, which
+ // reads it, rather than `ruleBody`, which reads the superseded one.
+ //
+ // Do NOT add `expect(list).not.toBe(ruleBody(...))` to pin that choice. It
+ // cannot fail: `lastRuleBody` slices from the leading newline and `ruleBody`
+ // from `lineStart + 1`, so the two differ by whitespace whatever the sheet
+ // contains (measured — deleting the superseded block outright leaves this
+ // suite 153/153 green). Trimming the compare to make it bite is worse: it
+ // then reds on that very de-dup, which is harmless. The two `toContain`s
+ // above are what carry the property, and they fail in both hazard
+ // directions — declaration missing from the effective block, or the
+ // effective block gone so this falls through to the old one.
+ });
+
test('history recovery is positioned against the chat viewport, outside the scroller', () => {
const transcript = ruleBody(v2, '.v2-thread__transcript');
expect(transcript).toContain('position: relative');
diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx
index 6c482acc3..8c09bd064 100644
--- a/frontend/src/v2/components/V2PodsSidebar.tsx
+++ b/frontend/src/v2/components/V2PodsSidebar.tsx
@@ -197,7 +197,7 @@ const V2PodsSidebar: React.FC = ({
const { pinned, toggle: togglePin } = useV2Pinned();
const ownPodsState = useV2Pods();
const {
- pods, loading, error, createPod,
+ pods, loading, error, createPod, refresh: refreshPods,
} = podsState || ownPodsState;
const searchRef = useRef(null);
const [query, setQuery] = useState('');
@@ -219,14 +219,35 @@ const V2PodsSidebar: React.FC = ({
}, [createReturnToConnectors]);
// The visit log is written by the layout when a pod opens; re-read it here so
- // Recent reorders without a reload. Times refresh once a minute.
+ // Recent reorders without a reload.
useEffect(() => {
setVisits(readPodVisits());
}, [selectedPodId]);
+
+ // Every row's time is a DATUM from the last /api/pods; the minute tick only
+ // recomputes the label, so a tab left open keeps ageing a frozen timestamp and
+ // reads further from the truth the longer it stays open (TASK-184). So the tick
+ // also re-reads the datum, and so does becoming visible — the moment a row is
+ // actually read. Silent, so a poll never blinks the list into its spinner; and
+ // foreground-only, so a background tab costs nothing.
+ const refreshPodsRef = useRef(refreshPods);
useEffect(() => {
- const timer = window.setInterval(() => setNow(Date.now()), 60 * 1000);
- return () => window.clearInterval(timer);
+ refreshPodsRef.current = refreshPods;
+ }, [refreshPods]);
+ const tick = useCallback(() => {
+ setNow(Date.now());
+ if (document.visibilityState === 'visible') void refreshPodsRef.current?.({ silent: true });
}, []);
+ useEffect(() => {
+ const timer = window.setInterval(tick, 60 * 1000);
+ // visibilitychange fires in both directions; `tick` decides what to do.
+ const onVisibility = () => tick();
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => {
+ window.clearInterval(timer);
+ document.removeEventListener('visibilitychange', onVisibility);
+ };
+ }, [tick]);
// ⌘K / Ctrl+K focuses the search box from anywhere in the shell.
useEffect(() => {
diff --git a/frontend/src/v2/hooks/useV2Pods.ts b/frontend/src/v2/hooks/useV2Pods.ts
index a3025ebc9..23cb82748 100644
--- a/frontend/src/v2/hooks/useV2Pods.ts
+++ b/frontend/src/v2/hooks/useV2Pods.ts
@@ -35,7 +35,12 @@ export interface UseV2PodsResult {
pods: V2Pod[];
loading: boolean;
error: string | null;
- refresh: () => Promise;
+ // `silent` re-reads the list in place: it swaps the data on success without
+ // setting `loading` or an error, so a background poll cannot blink a list
+ // into its spinner or replace the last good rows with a transient failure.
+ // Non-silent is the mount/retry path and owns the loading state. Both modes
+ // clear an existing error on success — see the success path below.
+ refresh: (options?: { silent?: boolean }) => Promise;
createPod: (
name: string,
description?: string,
@@ -52,17 +57,30 @@ export const useV2Pods = (): UseV2PodsResult => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const refresh = useCallback(async () => {
- setLoading(true);
- setError(null);
+ const refresh = useCallback(async (options?: { silent?: boolean }) => {
+ const silent = Boolean(options?.silent);
+ if (!silent) {
+ setLoading(true);
+ setError(null);
+ }
try {
const data = await api.get('/api/pods');
setPods(Array.isArray(data) ? data : []);
+ // A successful read is evidence the stored error is stale, so it clears
+ // it — in BOTH modes. Without this the silent poll could never recover
+ // the view it was added for: V2PodsSidebar renders the list only on
+ // `!loading && !error`, so a failed first load hid the rows behind an
+ // error that a later successful poll could refresh the data but never
+ // un-hide. Silence still means a poll never SETS a failure.
+ setError(null);
} catch (err) {
+ // A failed poll keeps the last good rows and the last error: silence is
+ // the point. Only the mount/retry path may surface a failure.
+ if (silent) return;
const e = err as { response?: { data?: { error?: string; msg?: string } }; message?: string };
setError(e.response?.data?.error || e.response?.data?.msg || e.message || 'Failed to load pods');
} finally {
- setLoading(false);
+ if (!silent) setLoading(false);
}
}, [api]);
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index f271359ae..072a303ef 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -11002,6 +11002,13 @@ body.modern-ui.v2-canvas {
gap: 0;
padding: 0 0 8px;
overflow-y: auto;
+ /* The minute poll re-reads /api/pods and the rows re-sort by lastMessage, so
+ a message to any pod moves a row and native anchoring drags the list to
+ keep that row's viewport offset — 4 of 17 rows stayed in view (ux-lead
+ gate on #1989). Opting this scroller out holds 16 of 17. Unlike
+ .v2-chat__messages, which keeps anchoring at rest for late media above the
+ viewport, these rows are fixed-height text with no late layout to lose. */
+ overflow-anchor: none;
}
.v2-pods__section {