diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b73a00dc..cee6de39 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { + 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, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ff7102a4..7f7953e7 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { + 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) => + `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; + const leadSessionFingerprints = new Set(); + 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(() => { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 509321c7..01ae000b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index f5ab238a..5966fc35 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_SEND_MESSAGE, teamName, request); }, + getMessagesPage: async ( + teamName: string, + options?: { beforeTimestamp?: string; limit?: number } + ) => { + return invokeIpcWithResult(TEAM_GET_MESSAGES_PAGE, teamName, options); + }, createTask: async (teamName: string, request: CreateTaskRequest) => { return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d24b976c..b05c635f 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -723,6 +723,9 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { 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 => { throw new Error('Team task creation is not available in browser mode'); }, diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 78965e85..7ce5a006 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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([]); + const [nextCursor, setNextCursor] = useState(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(null); const sidebarScrollRef = useRef(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(() => { @@ -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 && ( +
+ +
+ )} + {hasMore && ( +
+ +
+ )} Promise; cancelProvisioning: (runId: string) => Promise; sendMessage: (teamName: string, request: SendMessageRequest) => Promise; + getMessagesPage: ( + teamName: string, + options?: { beforeTimestamp?: string; limit?: number } + ) => Promise; createTask: (teamName: string, request: CreateTaskRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b8f4e817..aad4d036 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 {