feat: paginated messages API (team:getMessagesPage)

New cursor-based IPC endpoint for the messages timeline panel:
- team:getMessagesPage(teamName, { beforeTimestamp?, limit? })
  returns { messages, nextCursor, hasMore }
- Cursor is timestamp-based — stable under live message insertion
- Default page size 50, max 200

getTeamData no longer includes messages in its response, eliminating
the ~1MB messages payload from every team refresh. Messages are now
fetched independently by MessagesPanel.

MessagesPanel changes:
- Fetches initial page on mount via getMessagesPage API
- Auto-refreshes newest page every 5s when team is alive
- "Load older messages" button for pagination
- Falls back to prop messages if API fails (graceful degradation)
This commit is contained in:
Artem Rootman 2026-04-05 17:37:26 +00:00
parent f07eb914ae
commit 0c0e0240a3
No known key found for this signature in database
GPG key ID: B7C30676209A822C
8 changed files with 225 additions and 12 deletions

View file

@ -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,

View file

@ -51,6 +51,7 @@ import type {
CreateTaskRequest,
GlobalTask,
InboxMessage,
MessagesPage,
KanbanColumnId,
KanbanState,
ResolvedTeamMember,
@ -722,25 +723,78 @@ 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;
const cappedMessages =
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
// Messages are now served separately via getMessagesPage() for pagination.
// Sending an empty array here keeps the TeamData shape unchanged while
// eliminating the ~1MB messages payload from every getTeamData refresh.
return {
teamName,
config,
tasks: tasksWithKanban,
members,
messages: cappedMessages,
messages: [],
kanbanState,
processes,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
/**
* 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(() => {

View file

@ -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';

View file

@ -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);
},

View file

@ -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');
},

View file

@ -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}

View file

@ -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>;

View file

@ -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 {