merge: add paginated messages API into oom-issues branch
This commit is contained in:
commit
270eb547cb
8 changed files with 225 additions and 8 deletions
|
|
@ -21,6 +21,7 @@ import {
|
|||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
|
|
@ -150,6 +151,7 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
MessagesPage,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMessageNotificationData,
|
||||
|
|
@ -334,6 +336,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||
|
|
@ -400,6 +403,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||
|
|
@ -1391,6 +1395,29 @@ function buildMessageDeliveryText(
|
|||
return [...hiddenBlocks, baseText].join('\n\n');
|
||||
}
|
||||
|
||||
async function handleGetMessagesPage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
options: unknown
|
||||
): Promise<IpcResult<MessagesPage>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||
beforeTimestamp?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||
const beforeTimestamp =
|
||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
||||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSendMessage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import type {
|
|||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
KanbanColumnId,
|
||||
KanbanState,
|
||||
ResolvedTeamMember,
|
||||
|
|
@ -722,10 +723,10 @@ export class TeamDataService {
|
|||
this.processHealthTeams.delete(teamName);
|
||||
}
|
||||
|
||||
// Cap messages to keep IPC/postMessage payloads under ~300KB.
|
||||
// Without this, teams with 2000+ messages produce 3MB+ payloads that
|
||||
// stall Chromium's IPC serialization for ~1 second per transfer.
|
||||
const MAX_RETURN_MESSAGES = 200;
|
||||
// Cap messages to keep IPC payloads small. Full history is available
|
||||
// via the paginated getMessagesPage() API. We still include a small
|
||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
||||
const MAX_RETURN_MESSAGES = 50;
|
||||
const cappedMessages =
|
||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
||||
|
||||
|
|
@ -741,6 +742,63 @@ export class TeamDataService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated message retrieval for the messages panel.
|
||||
* Uses cursor-based pagination by timestamp to handle live message insertion.
|
||||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
// Collect all messages from the same sources as getTeamData
|
||||
let messages: InboxMessage[] = [];
|
||||
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
|
||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
|
||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
||||
|
||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
if (m.to) return true;
|
||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
||||
});
|
||||
}
|
||||
|
||||
// Sort newest-first
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
// Apply cursor filter
|
||||
if (options.beforeTimestamp) {
|
||||
const cursorMs = Date.parse(options.beforeTimestamp);
|
||||
messages = messages.filter((m) => Date.parse(m.timestamp) < cursorMs);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const hasMore = messages.length > options.limit;
|
||||
const page = messages.slice(0, options.limit);
|
||||
const nextCursor = hasMore && page.length > 0 ? page[page.length - 1].timestamp : null;
|
||||
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
}
|
||||
|
||||
startProcessHealthPolling(): void {
|
||||
if (this.processHealthTimer) return;
|
||||
this.processHealthTimer = setInterval(() => {
|
||||
|
|
|
|||
|
|
@ -231,6 +231,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
|||
/** Send inbox message to team member */
|
||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||
|
||||
/** Paginated messages for timeline/messages panel */
|
||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||
|
||||
/** Request review for task */
|
||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ import {
|
|||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
|
|
@ -258,6 +259,7 @@ import type {
|
|||
ScheduleRun,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
MessagesPage,
|
||||
SessionsByIdsOptions,
|
||||
SessionsPaginationOptions,
|
||||
SnippetDiff,
|
||||
|
|
@ -855,6 +857,12 @@ const electronAPI: ElectronAPI = {
|
|||
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||
return invokeIpcWithResult<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
|
||||
},
|
||||
getMessagesPage: async (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
) => {
|
||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||
},
|
||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -723,6 +723,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<SendMessageResult> => {
|
||||
throw new Error('Team messaging is not available in browser mode');
|
||||
},
|
||||
getMessagesPage: async () => {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
},
|
||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||
throw new Error('Team task creation is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -126,6 +127,84 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}))
|
||||
);
|
||||
|
||||
// ── Paginated message fetching ──
|
||||
// Messages are now fetched via getMessagesPage API instead of coming
|
||||
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
|
||||
const PAGE_SIZE = 50;
|
||||
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
// Initial fetch on mount or team change
|
||||
useEffect(() => {
|
||||
const id = ++fetchIdRef.current;
|
||||
setMessagesLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
if (fetchIdRef.current !== id) return;
|
||||
setFetchedMessages(page.messages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// Fallback: use prop messages if API fails
|
||||
if (fetchIdRef.current === id && messages.length > 0) {
|
||||
setFetchedMessages(messages);
|
||||
}
|
||||
} finally {
|
||||
if (fetchIdRef.current === id) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
|
||||
|
||||
// Auto-refresh: poll for new messages (newest page only)
|
||||
useEffect(() => {
|
||||
if (!isTeamAlive && leadActivity !== 'active') return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
setFetchedMessages((prev) => {
|
||||
// Merge: keep older messages that aren't in the new page
|
||||
const newIds = new Set(page.messages.map((m) => m.messageId ?? m.timestamp));
|
||||
const older = prev.filter(
|
||||
(m) =>
|
||||
!newIds.has(m.messageId ?? m.timestamp) &&
|
||||
!page.messages.some((n) => n.timestamp === m.timestamp && n.from === m.from)
|
||||
);
|
||||
return [...page.messages, ...older];
|
||||
});
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [teamName, isTeamAlive, leadActivity]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || messagesLoading) return;
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setFetchedMessages((prev) => [...prev, ...page.messages]);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [teamName, nextCursor, messagesLoading]);
|
||||
|
||||
// Use fetched messages, fall back to prop messages during initial load
|
||||
const effectiveMessages = fetchedMessages.length > 0 ? fetchedMessages : messages;
|
||||
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleExpandContent = useCallback(() => {
|
||||
|
|
@ -195,12 +274,12 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}, [position, sidebarScrollTop]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
return filterTeamMessages(messages, {
|
||||
return filterTeamMessages(effectiveMessages, {
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
searchQuery: messagesSearchQuery,
|
||||
});
|
||||
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
}, [effectiveMessages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
|
||||
// Resolve the expanded item from filtered messages
|
||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||
|
|
@ -263,7 +342,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
const next = { ...pendingRepliesByMember };
|
||||
let changed = false;
|
||||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||
const hasReply = messages.some((m) => {
|
||||
const hasReply = effectiveMessages.some((m) => {
|
||||
if (m.from !== memberName) return false;
|
||||
const ts = Date.parse(m.timestamp);
|
||||
return Number.isFinite(ts) && ts > sentAtMs;
|
||||
|
|
@ -274,7 +353,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}
|
||||
}
|
||||
if (changed) onPendingReplyChange(() => next);
|
||||
}, [messages, pendingRepliesByMember, onPendingReplyChange]);
|
||||
}, [effectiveMessages, pendingRepliesByMember, onPendingReplyChange]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(
|
||||
|
|
@ -434,6 +513,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onExpandItem={handleExpandItem}
|
||||
onExpandContent={handleExpandContent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-text-muted"
|
||||
disabled={messagesLoading}
|
||||
onClick={() => void loadOlderMessages()}
|
||||
>
|
||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageExpandDialog
|
||||
expandedItem={expandedItem}
|
||||
open={expandedItemKey !== null}
|
||||
|
|
@ -600,6 +692,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onExpandItem={handleExpandItem}
|
||||
onExpandContent={handleExpandContent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-text-muted"
|
||||
disabled={messagesLoading}
|
||||
onClick={() => void loadOlderMessages()}
|
||||
>
|
||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageExpandDialog
|
||||
expandedItem={expandedItem}
|
||||
open={expandedItemKey !== null}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import type {
|
|||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
MessagesPage,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
|
|
@ -432,6 +433,10 @@ export interface TeamsAPI {
|
|||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||
getMessagesPage: (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
) => Promise<MessagesPage>;
|
||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -386,6 +386,14 @@ export interface InboxMessage {
|
|||
commandOutput?: CommandOutputMeta;
|
||||
}
|
||||
|
||||
/** Cursor-based paginated messages response. */
|
||||
export interface MessagesPage {
|
||||
messages: InboxMessage[];
|
||||
/** ISO timestamp cursor for fetching older messages. Null when no more pages. */
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
|
|
|||
Loading…
Reference in a new issue