agent-ecosystem/src/renderer/components/team/members/MemberMessagesTab.tsx
2026-04-18 14:13:15 +03:00

320 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { api } from '@renderer/api';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
resolveMessageRenderProps,
} from '@renderer/components/team/activity/activityMessageContext';
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { Button } from '@renderer/components/ui/button';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isLeadMember } from '@shared/utils/leadDetection';
import type { MemberActivityFilter } from './memberDetailTypes';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberMessagesTabProps {
messages: InboxMessage[];
teamName: string;
memberName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
initialFilter?: MemberActivityFilter;
onCreateTask?: (subject: string, description: string) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void;
}
const MAX_MESSAGES = 100;
const MEMBER_MESSAGES_PAGE_SIZE = 50;
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'messages', label: 'Messages' },
{ value: 'comments', label: 'Comments' },
];
export const MemberMessagesTab = ({
messages,
teamName,
memberName,
members,
tasks,
initialFilter = 'all',
onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => {
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [initialPageLoading, setInitialPageLoading] = useState(false);
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { readSet } = useTeamMessagesRead(teamName);
const leadId = `lead:${teamName}`;
const leadName = useMemo(
() => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`,
[members, teamName]
);
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
useEffect(() => {
setActivityFilter(initialFilter);
}, [initialFilter, memberName, teamName]);
useEffect(() => {
let cancelled = false;
setPagedMessages([]);
setNextCursor(null);
setHasMore(false);
setInitialPageLoading(true);
void (async () => {
try {
const page = await api.teams.getMessagesPage(teamName, {
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
if (cancelled) return;
const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName
);
setPagedMessages(memberPageMessages);
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
if (!cancelled) {
setPagedMessages([]);
setNextCursor(null);
setHasMore(false);
}
} finally {
if (!cancelled) {
setInitialPageLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [teamName, memberName]);
const loadOlderMessages = useCallback(async () => {
if (!nextCursor || loadingOlderMessages) return;
setLoadingOlderMessages(true);
try {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: nextCursor,
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName
);
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// best-effort
} finally {
setLoadingOlderMessages(false);
}
}, [loadingOlderMessages, memberName, nextCursor, teamName]);
const effectiveMessages = useMemo(
() => mergeTeamMessages(messages, pagedMessages),
[messages, pagedMessages]
);
const filteredMessages = useMemo(
() =>
filterTeamMessages(effectiveMessages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
}),
[effectiveMessages]
);
const activityEntries = useMemo(() => {
const entriesByOwner = buildInlineActivityEntries({
data: {
members,
tasks,
messages: filteredMessages,
},
teamName,
leadId,
leadName,
ownerNodeIds,
});
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
const displayEntries = useMemo(() => {
switch (activityFilter) {
case 'messages':
return activityEntries.filter(
(entry) => entry.message.messageKind !== 'task_comment_notification'
);
case 'comments':
return activityEntries.filter(
(entry) => entry.message.messageKind === 'task_comment_notification'
);
default:
return activityEntries;
}
}, [activityEntries, activityFilter]);
const expandedItemsByKey = useMemo(() => {
const items = new Map<string, TimelineItem>();
for (const entry of displayEntries) {
items.set(toMessageKey(entry.message), { type: 'message', message: entry.message });
}
return items;
}, [displayEntries]);
const handleExpandItem = useCallback(
(key: string) => {
const next = expandedItemsByKey.get(key);
if (next) {
setExpandedItem(next);
}
},
[expandedItemsByKey]
);
const handleTaskIdClick = useCallback(
(taskId: string) => {
const task = taskMap.get(taskId) ?? tasks.find((candidate) => candidate.displayId === taskId);
if (task) {
onTaskClick?.(task);
}
},
[onTaskClick, taskMap, tasks]
);
const emptyStateText = initialPageLoading
? 'Loading activity...'
: activityFilter === 'comments'
? 'No comments for this member'
: activityFilter === 'messages'
? hasMore
? 'No loaded messages for this member yet'
: 'No messages with this member'
: 'No activity with this member';
const canLoadOlderMessages =
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{FILTER_OPTIONS.map((option) => {
const isActive = activityFilter === option.value;
return (
<button
key={option.value}
type="button"
className={[
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
isActive
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] text-[var(--color-text)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]',
].join(' ')}
onClick={() => setActivityFilter(option.value)}
>
{option.label}
</button>
);
})}
</div>
<div className="max-h-[320px] space-y-2 overflow-y-auto">
{displayEntries.length > 0 ? (
displayEntries.map((entry, index) => {
const messageKey = toMessageKey(entry.message);
const renderProps = resolveMessageRenderProps(entry.message, messageContext);
const timelineItem: TimelineItem = { type: 'message', message: entry.message };
const isUnread = !entry.message.read && !readSet.has(messageKey);
return (
<div
key={entry.graphItem.id}
className="cursor-pointer"
role="button"
tabIndex={0}
onClick={() => setExpandedItem(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setExpandedItem(timelineItem);
}
}}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
onCreateTask={onCreateTask}
onTaskIdClick={handleTaskIdClick}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
zebraShade={index % 2 === 1}
/>
</div>
);
})
) : (
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
{emptyStateText}
</div>
)}
{canLoadOlderMessages && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
</div>
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItem !== null}
onOpenChange={(open) => {
if (!open) {
setExpandedItem(null);
}
}}
teamName={teamName}
members={members}
onTaskIdClick={handleTaskIdClick}
onCreateTaskFromMessage={onCreateTask}
/>
</div>
);
};