Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@
},
"errors": {
"uploadFailed": "Failed to upload file. Please try again."
}
},
"loadOlder": "Load older messages",
"loadingOlder": "Loading…"
},
"landing": {
"nav": {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@
},
"errors": {
"uploadFailed": "文件上传失败,请重试。"
}
},
"loadOlder": "加载更早的消息",
"loadingOlder": "加载中…"
},
"landing": {
"nav": {
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/v2/__tests__/useV2PodDetailPagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 46 additions & 4 deletions frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -170,7 +172,10 @@ const Icon = ({ d }: { d: string }) => (
const V2PodChat: React.FC<V2PodChatProps> = ({ 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();
Expand All @@ -190,6 +195,7 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, firstRunVisible = false,
const deliveryHintShownPodsRef = useRef<Set<string>>(new Set());
const [mode, setMode] = useState<PodMode>(pod ? readMode(pod._id) : 'plan');
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const composerInputRef = useRef<HTMLTextAreaElement | null>(null);
const mentionDropdownRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -302,9 +308,33 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ 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<number | null>(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
Expand Down Expand Up @@ -869,7 +899,19 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, firstRunVisible = false,
)}
</header>

<div className="v2-chat__messages">
<div className="v2-chat__messages" ref={messagesContainerRef}>
{hasMore && (
<div className="v2-chat__older">
<button
type="button"
className="v2-chat__older-btn"
onClick={handleLoadOlder}
disabled={loadingOlder}
>
{loadingOlder ? t('podChat.loadingOlder') : t('podChat.loadOlder')}
</button>
</div>
)}
{error && (
<div className="v2-chat__error">
{error}
Expand Down
73 changes: 69 additions & 4 deletions frontend/src/v2/hooks/useV2PodDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
refresh: () => Promise<void>;
sendMessage: (content: string, messageType?: string, replyToMessageId?: string) => Promise<V2Message | null>;
}

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<string, V2Message>();
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;
Expand Down Expand Up @@ -162,6 +179,8 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
const [agents, setAgents] = useState<V2Agent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);

const fetchPod = useCallback(async (id: string) => {
const result = await api.get<V2Pod>(`/api/pods/${id}`, { timeout: REQUEST_TIMEOUT_MS });
Expand All @@ -170,16 +189,60 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {

const fetchMessages = useCallback(async (id: string) => {
try {
const data = await api.get<V2Message[]>(`/api/messages/${id}?limit=50`, { timeout: REQUEST_TIMEOUT_MS });
const data = await api.get<V2Message[]>(
`/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<V2Message[]>(
`/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<AgentsResponse>(`/api/registry/pods/${id}/agents`, { timeout: REQUEST_TIMEOUT_MS });
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading