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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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. */
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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