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 {