fix message pagination consumers

This commit is contained in:
iliya 2026-04-05 21:54:52 +03:00
parent 8570ed13fd
commit dc0627c285
5 changed files with 152 additions and 22 deletions

View file

@ -1,9 +1,11 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
@ -40,6 +42,8 @@ interface MemberDetailDialogProps {
onViewMemberChanges?: (memberName: string, filePath?: string) => void;
}
const MEMBER_MESSAGES_PAGE_SIZE = 200;
export const MemberDetailDialog = ({
open,
member,
@ -63,10 +67,56 @@ export const MemberDetailDialog = ({
[tasks, member]
);
const memberMessages = useMemo(
const seedMemberMessages = useMemo(
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
[messages, member]
);
const [pagedMemberMessages, setPagedMemberMessages] = useState<InboxMessage[] | null>(null);
useEffect(() => {
if (!open || !member) {
setPagedMemberMessages(null);
return;
}
let cancelled = false;
setPagedMemberMessages(null);
void (async () => {
let cursor: string | undefined;
let hasMore = true;
let allMessages: InboxMessage[] = [];
while (!cancelled && hasMore) {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: cursor,
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
allMessages = mergeTeamMessages(allMessages, page.messages);
hasMore = page.hasMore && page.nextCursor != null;
cursor = page.nextCursor ?? undefined;
}
if (cancelled) return;
setPagedMemberMessages(
allMessages.filter((message) => message.from === member.name || message.to === member.name)
);
})().catch(() => {
if (!cancelled) {
setPagedMemberMessages([]);
}
});
return () => {
cancelled = true;
};
}, [open, teamName, member?.name]);
const memberMessages = useMemo(
() => mergeTeamMessages(seedMemberMessages, pagedMemberMessages ?? []),
[seedMemberMessages, pagedMemberMessages]
);
const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length,

View file

@ -8,6 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { useShallow } from 'zustand/react/shallow';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
@ -166,13 +167,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const interval = setInterval(async () => {
try {
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
setFetchedMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`));
const newMessages = page.messages.filter(
(m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`)
);
return newMessages.length > 0 ? [...newMessages, ...prev] : prev;
});
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
} catch {
// best-effort
}
@ -188,14 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
beforeTimestamp: nextCursor,
limit: PAGE_SIZE,
});
// Dedup: only append messages we don't already have
setFetchedMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.messageId ?? `${m.timestamp}\0${m.from}`));
const newMessages = page.messages.filter(
(m) => !existingIds.has(m.messageId ?? `${m.timestamp}\0${m.from}`)
);
return [...prev, ...newMessages];
});
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
@ -206,7 +194,10 @@ export const MessagesPanel = memo(function MessagesPanel({
}, [teamName, nextCursor, messagesLoading]);
// Use fetched messages, fall back to prop messages during initial load
const effectiveMessages = fetchedMessages.length > 0 ? fetchedMessages : messages;
const effectiveMessages = useMemo(() => {
if (fetchedMessages.length === 0) return messages;
return mergeTeamMessages(fetchedMessages, messages);
}, [fetchedMessages, messages]);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
@ -437,7 +428,7 @@ export const MessagesPanel = memo(function MessagesPanel({
</div>
<MessagesFilterPopover
filter={messagesFilter}
messages={messages}
messages={effectiveMessages}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
@ -485,7 +476,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<StatusBlock
members={members}
tasks={tasks}
messages={messages}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
position="inline"
onMemberClick={onMemberClick}
@ -663,7 +654,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<StatusBlock
members={members}
tasks={tasks}
messages={messages}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
position="sidebar"
onMemberClick={onMemberClick}

View file

@ -0,0 +1,27 @@
import { toMessageKey } from './teamMessageKey';
import type { InboxMessage } from '@shared/types';
function compareMessages(a: InboxMessage, b: InboxMessage): number {
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
if (diff !== 0) return diff;
return toMessageKey(a).localeCompare(toMessageKey(b));
}
/**
* Merge multiple message arrays into one newest-first list with stable deduplication.
*
* Later arrays win for duplicate keys so callers can overlay fresher/live message data
* on top of paginated history without losing already-loaded older pages.
*/
export function mergeTeamMessages(...messageLists: readonly InboxMessage[][]): InboxMessage[] {
const merged = new Map<string, InboxMessage>();
for (const list of messageLists) {
for (const message of list) {
merged.set(toMessageKey(message), message);
}
}
return Array.from(merged.values()).sort(compareMessages);
}

View file

@ -389,7 +389,7 @@ export interface InboxMessage {
/** Cursor-based paginated messages response. */
export interface MessagesPage {
messages: InboxMessage[];
/** ISO timestamp cursor for fetching older messages. Null when no more pages. */
/** Opaque cursor string for fetching older messages. Null when no more pages. */
nextCursor: string | null;
hasMore: boolean;
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { mergeTeamMessages } from '../../../src/renderer/utils/mergeTeamMessages';
import type { InboxMessage } from '@shared/types';
function makeMessage(
overrides: Partial<InboxMessage> & Pick<InboxMessage, 'from' | 'text' | 'timestamp'>
): InboxMessage {
const { from, text, timestamp, ...rest } = overrides;
return {
from,
text,
timestamp,
read: rest.read ?? true,
...rest,
};
}
describe('mergeTeamMessages', () => {
it('deduplicates by stable message key and keeps newest-first order', () => {
const older = makeMessage({
from: 'alice',
text: 'older',
timestamp: '2026-01-01T00:00:00.000Z',
messageId: 'm1',
});
const newer = makeMessage({
from: 'bob',
text: 'newer',
timestamp: '2026-01-01T00:00:01.000Z',
messageId: 'm2',
});
const merged = mergeTeamMessages([older], [newer]);
expect(merged.map((message) => message.messageId)).toEqual(['m2', 'm1']);
});
it('lets later arrays overlay duplicate messages', () => {
const persisted = makeMessage({
from: 'team-lead',
text: 'hello',
timestamp: '2026-01-01T00:00:00.000Z',
messageId: 'm1',
summary: 'persisted',
});
const live = makeMessage({
from: 'team-lead',
text: 'hello',
timestamp: '2026-01-01T00:00:00.000Z',
messageId: 'm1',
summary: 'live',
source: 'lead_process',
});
const merged = mergeTeamMessages([persisted], [live]);
expect(merged).toHaveLength(1);
expect(merged[0].summary).toBe('live');
expect(merged[0].source).toBe('lead_process');
});
});