fix message pagination consumers
This commit is contained in:
parent
8570ed13fd
commit
dc0627c285
5 changed files with 152 additions and 22 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
27
src/renderer/utils/mergeTeamMessages.ts
Normal file
27
src/renderer/utils/mergeTeamMessages.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
62
test/renderer/utils/mergeTeamMessages.test.ts
Normal file
62
test/renderer/utils/mergeTeamMessages.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue