320 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|