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 { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
@ -40,6 +42,8 @@ interface MemberDetailDialogProps {
onViewMemberChanges?: (memberName: string, filePath?: string) => void; onViewMemberChanges?: (memberName: string, filePath?: string) => void;
} }
const MEMBER_MESSAGES_PAGE_SIZE = 200;
export const MemberDetailDialog = ({ export const MemberDetailDialog = ({
open, open,
member, member,
@ -63,10 +67,56 @@ export const MemberDetailDialog = ({
[tasks, member] [tasks, member]
); );
const memberMessages = useMemo( const seedMemberMessages = useMemo(
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
[messages, member] [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( const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length, () => 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 { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
@ -166,13 +167,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
setFetchedMessages((prev) => { setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
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;
});
} catch { } catch {
// best-effort // best-effort
} }
@ -188,14 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
beforeTimestamp: nextCursor, beforeTimestamp: nextCursor,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}); });
// Dedup: only append messages we don't already have setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
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];
});
setNextCursor(page.nextCursor); setNextCursor(page.nextCursor);
setHasMore(page.hasMore); setHasMore(page.hasMore);
} catch { } catch {
@ -206,7 +194,10 @@ export const MessagesPanel = memo(function MessagesPanel({
}, [teamName, nextCursor, messagesLoading]); }, [teamName, nextCursor, messagesLoading]);
// Use fetched messages, fall back to prop messages during initial load // 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 composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null); const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
@ -437,7 +428,7 @@ export const MessagesPanel = memo(function MessagesPanel({
</div> </div>
<MessagesFilterPopover <MessagesFilterPopover
filter={messagesFilter} filter={messagesFilter}
messages={messages} messages={effectiveMessages}
open={messagesFilterOpen} open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen} onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter} onApply={setMessagesFilter}
@ -485,7 +476,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<StatusBlock <StatusBlock
members={members} members={members}
tasks={tasks} tasks={tasks}
messages={messages} messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
position="inline" position="inline"
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
@ -663,7 +654,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<StatusBlock <StatusBlock
members={members} members={members}
tasks={tasks} tasks={tasks}
messages={messages} messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
position="sidebar" position="sidebar"
onMemberClick={onMemberClick} 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. */ /** Cursor-based paginated messages response. */
export interface MessagesPage { export interface MessagesPage {
messages: InboxMessage[]; 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; nextCursor: string | null;
hasMore: boolean; 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');
});
});