From dc0627c2853f4c7e69b6518b5341e4451fdacc83 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 21:54:52 +0300 Subject: [PATCH] fix message pagination consumers --- .../team/members/MemberDetailDialog.tsx | 54 +++++++++++++++- .../team/messages/MessagesPanel.tsx | 29 +++------ src/renderer/utils/mergeTeamMessages.ts | 27 ++++++++ src/shared/types/team.ts | 2 +- test/renderer/utils/mergeTeamMessages.test.ts | 62 +++++++++++++++++++ 5 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 src/renderer/utils/mergeTeamMessages.ts create mode 100644 test/renderer/utils/mergeTeamMessages.test.ts diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 796545ad..34ff49a0 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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(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, diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9294d9b5..e23e9de8 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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(null); const sidebarScrollRef = useRef(null); @@ -437,7 +428,7 @@ export const MessagesPanel = memo(function MessagesPanel({ (); + + for (const list of messageLists) { + for (const message of list) { + merged.set(toMessageKey(message), message); + } + } + + return Array.from(merged.values()).sort(compareMessages); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index aad4d036..08a1e421 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; } diff --git a/test/renderer/utils/mergeTeamMessages.test.ts b/test/renderer/utils/mergeTeamMessages.test.ts new file mode 100644 index 00000000..d8da3645 --- /dev/null +++ b/test/renderer/utils/mergeTeamMessages.test.ts @@ -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 & Pick +): 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'); + }); +});