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:
parent
f07eb914ae
commit
0c0e0240a3
8 changed files with 225 additions and 12 deletions
|
|
@ -21,6 +21,7 @@ import {
|
||||||
TEAM_GET_CLAUDE_LOGS,
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
|
@ -150,6 +151,7 @@ import type {
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
TeamData,
|
||||||
|
MessagesPage,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
|
|
@ -334,6 +336,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||||
|
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||||
|
|
@ -400,6 +403,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||||
|
|
@ -1391,6 +1395,29 @@ function buildMessageDeliveryText(
|
||||||
return [...hiddenBlocks, baseText].join('\n\n');
|
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(
|
async function handleSendMessage(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import type {
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
MessagesPage,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
|
@ -722,25 +723,78 @@ export class TeamDataService {
|
||||||
this.processHealthTeams.delete(teamName);
|
this.processHealthTeams.delete(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap messages to keep IPC/postMessage payloads under ~300KB.
|
// Messages are now served separately via getMessagesPage() for pagination.
|
||||||
// Without this, teams with 2000+ messages produce 3MB+ payloads that
|
// Sending an empty array here keeps the TeamData shape unchanged while
|
||||||
// stall Chromium's IPC serialization for ~1 second per transfer.
|
// eliminating the ~1MB messages payload from every getTeamData refresh.
|
||||||
const MAX_RETURN_MESSAGES = 200;
|
|
||||||
const cappedMessages =
|
|
||||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
tasks: tasksWithKanban,
|
tasks: tasksWithKanban,
|
||||||
members,
|
members,
|
||||||
messages: cappedMessages,
|
messages: [],
|
||||||
kanbanState,
|
kanbanState,
|
||||||
processes,
|
processes,
|
||||||
warnings: warnings.length > 0 ? warnings : undefined,
|
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 {
|
startProcessHealthPolling(): void {
|
||||||
if (this.processHealthTimer) return;
|
if (this.processHealthTimer) return;
|
||||||
this.processHealthTimer = setInterval(() => {
|
this.processHealthTimer = setInterval(() => {
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
||||||
/** Send inbox message to team member */
|
/** Send inbox message to team member */
|
||||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
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 */
|
/** Request review for task */
|
||||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ import {
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
|
|
@ -258,6 +259,7 @@ import type {
|
||||||
ScheduleRun,
|
ScheduleRun,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
MessagesPage,
|
||||||
SessionsByIdsOptions,
|
SessionsByIdsOptions,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
SnippetDiff,
|
SnippetDiff,
|
||||||
|
|
@ -855,6 +857,12 @@ const electronAPI: ElectronAPI = {
|
||||||
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||||
return invokeIpcWithResult<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
|
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) => {
|
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -723,6 +723,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<SendMessageResult> => {
|
): Promise<SendMessageResult> => {
|
||||||
throw new Error('Team messaging is not available in browser mode');
|
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> => {
|
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||||
throw new Error('Team task creation is not available in browser mode');
|
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 { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
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 composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const handleExpandContent = useCallback(() => {
|
const handleExpandContent = useCallback(() => {
|
||||||
|
|
@ -195,12 +274,12 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
}, [position, sidebarScrollTop]);
|
}, [position, sidebarScrollTop]);
|
||||||
|
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return filterTeamMessages(messages, {
|
return filterTeamMessages(effectiveMessages, {
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
searchQuery: messagesSearchQuery,
|
||||||
});
|
});
|
||||||
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
}, [effectiveMessages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
// Resolve the expanded item from filtered messages
|
// Resolve the expanded item from filtered messages
|
||||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||||
|
|
@ -263,7 +342,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
const next = { ...pendingRepliesByMember };
|
const next = { ...pendingRepliesByMember };
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||||
const hasReply = messages.some((m) => {
|
const hasReply = effectiveMessages.some((m) => {
|
||||||
if (m.from !== memberName) return false;
|
if (m.from !== memberName) return false;
|
||||||
const ts = Date.parse(m.timestamp);
|
const ts = Date.parse(m.timestamp);
|
||||||
return Number.isFinite(ts) && ts > sentAtMs;
|
return Number.isFinite(ts) && ts > sentAtMs;
|
||||||
|
|
@ -274,7 +353,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) onPendingReplyChange(() => next);
|
if (changed) onPendingReplyChange(() => next);
|
||||||
}, [messages, pendingRepliesByMember, onPendingReplyChange]);
|
}, [effectiveMessages, pendingRepliesByMember, onPendingReplyChange]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
(
|
(
|
||||||
|
|
@ -434,6 +513,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
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
|
<MessageExpandDialog
|
||||||
expandedItem={expandedItem}
|
expandedItem={expandedItem}
|
||||||
open={expandedItemKey !== null}
|
open={expandedItemKey !== null}
|
||||||
|
|
@ -600,6 +692,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
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
|
<MessageExpandDialog
|
||||||
expandedItem={expandedItem}
|
expandedItem={expandedItem}
|
||||||
open={expandedItemKey !== null}
|
open={expandedItemKey !== null}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import type {
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
MessagesPage,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskChangePresenceState,
|
TaskChangePresenceState,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -432,6 +433,10 @@ export interface TeamsAPI {
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
cancelProvisioning: (runId: string) => Promise<void>;
|
cancelProvisioning: (runId: string) => Promise<void>;
|
||||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||||
|
getMessagesPage: (
|
||||||
|
teamName: string,
|
||||||
|
options?: { beforeTimestamp?: string; limit?: number }
|
||||||
|
) => Promise<MessagesPage>;
|
||||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,14 @@ export interface InboxMessage {
|
||||||
commandOutput?: CommandOutputMeta;
|
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 type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||||
|
|
||||||
export interface SendMessageRequest {
|
export interface SendMessageRequest {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue