diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3ee1f651..f477c8cb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -324,7 +324,9 @@ }, "errors": { "uploadFailed": "Failed to upload file. Please try again." - } + }, + "loadOlder": "Load older messages", + "loadingOlder": "Loading…" }, "landing": { "nav": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 8c7a10d1..c302b78e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -324,7 +324,9 @@ }, "errors": { "uploadFailed": "文件上传失败,请重试。" - } + }, + "loadOlder": "加载更早的消息", + "loadingOlder": "加载中…" }, "landing": { "nav": { diff --git a/frontend/src/v2/__tests__/useV2PodDetailPagination.test.tsx b/frontend/src/v2/__tests__/useV2PodDetailPagination.test.tsx new file mode 100644 index 00000000..ed6ed136 --- /dev/null +++ b/frontend/src/v2/__tests__/useV2PodDetailPagination.test.tsx @@ -0,0 +1,136 @@ +// @ts-nocheck +// "Load older messages" worked on the anonymous showcase route but not in a +// pod you had actually joined — because it was never built there. The +// authenticated reader fired exactly one `?limit=50` request, tracked no +// cursor, and exposed no way to ask for anything older. The backend already +// accepted `before`, so the whole gap was client-side. +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useV2PodDetail } from '../hooks/useV2PodDetail'; + +const mockApi = { get: jest.fn(), post: jest.fn() }; +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); + +jest.mock('../../context/SocketContext', () => ({ + useSocket: () => ({ + socket: { on: jest.fn(), off: jest.fn() }, + connected: false, + joinPod: jest.fn(), + leavePod: jest.fn(), + }), +})); +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: { _id: 'me' } }), +})); + +const PAGE = 50; +const makePage = (prefix, count, startMinute) => Array.from({ length: count }, (_, i) => ({ + id: `${prefix}${i}`, + pod_id: 'p1', + user_id: 'u1', + content: `${prefix}-${i}`, + message_type: 'text', + created_at: new Date(Date.UTC(2026, 0, 1, 0, startMinute + i)).toISOString(), +})); + +const routeMock = (messagesByCall) => { + let call = 0; + mockApi.get.mockImplementation((url) => { + if (url.startsWith('/api/messages/')) { + const res = messagesByCall[Math.min(call, messagesByCall.length - 1)]; + call += 1; + return Promise.resolve(res); + } + if (url.includes('/agents')) return Promise.resolve({ agents: [] }); + return Promise.resolve({ _id: 'p1', name: 'Pod', members: [] }); + }); +}; + +describe('useV2PodDetail pagination', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reports hasMore when the first page comes back full', async () => { + routeMock([makePage('a', PAGE, 100)]); + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.messages).toHaveLength(PAGE)); + expect(result.current.hasMore).toBe(true); + }); + + it('reports no more history when the first page is short', async () => { + routeMock([makePage('a', 3, 100)]); + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.messages).toHaveLength(3)); + expect(result.current.hasMore).toBe(false); + }); + + it('loadOlder requests `before` the oldest held message and PREPENDS the page', async () => { + const first = makePage('new', PAGE, 100); + const older = makePage('old', 10, 0); + routeMock([first, older]); + + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.messages).toHaveLength(PAGE)); + + await act(async () => { await result.current.loadOlder(); }); + + const olderCall = mockApi.get.mock.calls + .map((c) => c[0]) + .find((u) => typeof u === 'string' && u.includes('before=')); + expect(olderCall).toContain(`before=${encodeURIComponent(first[0].created_at)}`); + + // Prepended, chronological, nothing lost. + expect(result.current.messages).toHaveLength(PAGE + 10); + expect(result.current.messages[0].id).toBe('old0'); + expect(result.current.messages[result.current.messages.length - 1].id).toBe(`new${PAGE - 1}`); + // A short older page means we have reached the beginning. + expect(result.current.hasMore).toBe(false); + }); + + it('does not duplicate a message the socket delivered while the page was in flight', async () => { + const first = makePage('a', PAGE, 100); + // The older page overlaps the newest list by one id. + const overlapping = [...makePage('old', 5, 0), first[0]]; + routeMock([first, overlapping]); + + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.messages).toHaveLength(PAGE)); + + await act(async () => { await result.current.loadOlder(); }); + + const ids = result.current.messages.map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + expect(result.current.messages).toHaveLength(PAGE + 5); + }); + + it('is a no-op with no messages, so the first render cannot fire a bogus cursor', async () => { + routeMock([[]]); + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.loadOlder(); }); + + expect(mockApi.get.mock.calls.filter((c) => String(c[0]).includes('before='))) + .toHaveLength(0); + }); + + it('keeps hasMore true when a page request fails, so the button can be retried', async () => { + const first = makePage('a', PAGE, 100); + let call = 0; + mockApi.get.mockImplementation((url) => { + if (url.startsWith('/api/messages/')) { + call += 1; + if (call === 1) return Promise.resolve(first); + return Promise.reject(new Error('network')); + } + if (url.includes('/agents')) return Promise.resolve({ agents: [] }); + return Promise.resolve({ _id: 'p1', name: 'Pod', members: [] }); + }); + + const { result } = renderHook(() => useV2PodDetail('p1')); + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + await act(async () => { await result.current.loadOlder(); }); + + expect(result.current.hasMore).toBe(true); + expect(result.current.loadingOlder).toBe(false); + }); +}); diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index 4d506b75..ca6f8c17 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, +} from 'react'; import V2Avatar from './V2Avatar'; import V2MessageBubble from './V2MessageBubble'; import { @@ -170,7 +172,10 @@ const Icon = ({ d }: { d: string }) => ( const V2PodChat: React.FC = ({ detail, firstRunVisible = false, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile, onOpenMobileNav }) => { const { t, i18n } = useTranslation(); const numberFormatter = new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language || 'en'); - const { pod, members, messages, agents, sendMessage, loading, error } = detail; + const { + pod, members, messages, agents, sendMessage, loading, error, + hasMore, loadingOlder, loadOlder, + } = detail; const api = useV2Api(); const { socket, connected } = useSocket(); const { currentUser } = useAuth(); @@ -190,6 +195,7 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, const deliveryHintShownPodsRef = useRef>(new Set()); const [mode, setMode] = useState(pod ? readMode(pod._id) : 'plan'); const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const fileInputRef = useRef(null); const composerInputRef = useRef(null); const mentionDropdownRef = useRef(null); @@ -302,9 +308,33 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, } }, [starterInviteUrl]); + // Auto-scroll belongs to NEW messages only. Keyed on `messages.length` this + // also fired when a page of history was prepended, yanking the reader from + // the older message they had just asked for straight back to the bottom — + // which reads as "load older is broken". Key on the newest message's id so + // prepends are ignored. + const newestMessageId = messages.length > 0 ? messages[messages.length - 1].id : null; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages.length]); + }, [newestMessageId]); + + // Prepending changes scrollHeight, so without this the viewport jumps. Hold + // the reader's position by restoring the distance from the BOTTOM, which is + // invariant under a prepend. + const scrollAnchorRef = useRef(null); + const handleLoadOlder = useCallback(async () => { + const el = messagesContainerRef.current; + scrollAnchorRef.current = el ? el.scrollHeight - el.scrollTop : null; + await loadOlder(); + }, [loadOlder]); + + useLayoutEffect(() => { + const el = messagesContainerRef.current; + const anchor = scrollAnchorRef.current; + if (!el || anchor == null) return; + el.scrollTop = el.scrollHeight - anchor; + scrollAnchorRef.current = null; + }, [messages]); // Removed: Lead-pill computation. The "Lead" label was just `idx === 0`, // which made whichever agent installed first (usually auto-installed @@ -869,7 +899,19 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, )} -
+
+ {hasMore && ( +
+ +
+ )} {error && (
{error} diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 3ad31c18..09b38196 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -103,12 +103,29 @@ export interface UseV2PodDetailResult { agents: V2Agent[]; loading: boolean; error: string | null; + /** A full page came back, so older history probably exists. */ + hasMore: boolean; + loadingOlder: boolean; + loadOlder: () => Promise; refresh: () => Promise; sendMessage: (content: string, messageType?: string, replyToMessageId?: string) => Promise; } const REQUEST_TIMEOUT_MS = 8000; const SEND_TIMEOUT_MS = 20000; +// One page of history. The backend clamps `limit` to [1,200]; 50 matches the +// showcase reader so both surfaces page at the same rate. +const PAGE_SIZE = 50; + +// Merge two message lists by id, newest-last. Used when prepending a page of +// history: a plain concat would duplicate anything the socket delivered while +// the request was in flight. +const mergeMessagesById = (a: V2Message[], b: V2Message[]): V2Message[] => { + const byId = new Map(); + for (const m of a) byId.set(String(m.id), m); + for (const m of b) byId.set(String(m.id), m); + return chronologicalMessages([...byId.values()]); +}; const normalizeMessage = (raw: V2Message): V2Message => { const rawUserId = raw.userId; @@ -162,6 +179,8 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); const fetchPod = useCallback(async (id: string) => { const result = await api.get(`/api/pods/${id}`, { timeout: REQUEST_TIMEOUT_MS }); @@ -170,16 +189,60 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { const fetchMessages = useCallback(async (id: string) => { try { - const data = await api.get(`/api/messages/${id}?limit=50`, { timeout: REQUEST_TIMEOUT_MS }); + const data = await api.get( + `/api/messages/${id}?limit=${PAGE_SIZE}`, + { timeout: REQUEST_TIMEOUT_MS }, + ); const list = Array.isArray(data) ? data : []; setMessages(chronologicalMessages(list.map(normalizeMessage))); + // This endpoint returns a bare array with no envelope, so end-of-history + // is inferred: a short page means there is nothing older behind it. + setHasMore(list.length >= PAGE_SIZE); } catch (err) { const e = err as { response?: { status?: number } }; - if (e.response?.status === 404) setMessages([]); - else throw err; + if (e.response?.status === 404) { + setMessages([]); + setHasMore(false); + } else throw err; } }, [api]); + /** + * Prepend one page of older history. + * + * Anchors on the oldest message currently held and asks for everything + * strictly before it (`before` is already supported server-side and is the + * same cursor the showcase reader uses). Results are merged by id rather + * than concatenated, because the socket may have delivered messages while + * the request was in flight. + */ + const loadOlder = useCallback(async () => { + if (!podId || loadingOlder) return; + const oldest = messages[0]; + if (!oldest) return; + const cursor = oldest.created_at || oldest.createdAt; + if (!cursor) return; + + setLoadingOlder(true); + try { + const data = await api.get( + `/api/messages/${podId}?limit=${PAGE_SIZE}&before=${encodeURIComponent(cursor)}`, + { timeout: REQUEST_TIMEOUT_MS }, + ); + const older = (Array.isArray(data) ? data : []).map(normalizeMessage); + if (older.length === 0) { + setHasMore(false); + return; + } + setMessages((prev) => mergeMessagesById(older, prev)); + setHasMore(older.length >= PAGE_SIZE); + } catch { + // Leave hasMore alone so the same button can be retried. + } finally { + setLoadingOlder(false); + } + }, [api, podId, messages, loadingOlder]); + const fetchAgents = useCallback(async (id: string) => { try { const data = await api.get(`/api/registry/pods/${id}/agents`, { timeout: REQUEST_TIMEOUT_MS }); @@ -323,7 +386,9 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { (m): m is V2PodMember => typeof m === 'object' && m !== null, ); - return { pod, members, messages, agents, loading, error, refresh, sendMessage }; + return { + pod, members, messages, agents, loading, error, hasMore, loadingOlder, loadOlder, refresh, sendMessage, + }; }; // Suppress unused import warning when MessagesResponse is not used below. diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index be9e1b77..2940faf6 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1505,6 +1505,26 @@ font-weight: 600; } +/* "Load older messages" control — the pod chat equivalent of the showcase + pager. Same pill treatment so the two reading surfaces match. */ +.v2-chat__older { display: flex; justify-content: center; padding: 4px 0 12px; } +.v2-chat__older-btn { + padding: 7px 16px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--v2-text-secondary); + background: var(--v2-surface); + border: 1px solid var(--v2-border); + border-radius: 999px; + cursor: pointer; +} +.v2-chat__older-btn:hover:not(:disabled) { + color: var(--v2-text-primary); + border-color: var(--v2-text-tertiary); +} +.v2-chat__older-btn:disabled { opacity: 0.6; cursor: default; } + .v2-chat__messages { flex: 1; overflow-y: auto;