feat(member-details): enhance member detail dialog with activity filtering and initial tab state

This commit is contained in:
777genius 2026-04-13 17:00:20 +03:00
parent 5b328a0f8a
commit 6fbf8518dc
13 changed files with 880 additions and 81 deletions

View file

@ -70,6 +70,7 @@ import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { KanbanSearchInput } from './kanban/KanbanSearchInput'; import { KanbanSearchInput } from './kanban/KanbanSearchInput';
import { TrashDialog } from './kanban/TrashDialog'; import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog';
@ -737,29 +738,35 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
member, member,
...props ...props
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } = const {
useStore( leadActivity,
useShallow((s) => ({ progress,
leadActivity: s.leadActivityByTeam[teamName], members: launchMembers,
progress: getCurrentProvisioningProgressForTeam(s, teamName), memberSpawnStatuses,
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], memberSpawnSnapshot,
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], spawnEntry,
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], } = useStore(
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, useShallow((s) => ({
})) leadActivity: s.leadActivityByTeam[teamName],
); progress: getCurrentProvisioningProgressForTeam(s, teamName),
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
}))
);
const isLaunchSettling = useMemo(() => { const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') { if (progress?.state !== 'ready') {
return false; return false;
} }
return getLaunchJoinState( return getLaunchJoinState(
getLaunchJoinMilestonesFromMembers({ getLaunchJoinMilestonesFromMembers({
members, members: launchMembers,
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshot, memberSpawnSnapshot,
}) })
).hasMembersStillJoining; ).hasMembersStillJoining;
}, [memberSpawnSnapshot, memberSpawnStatuses, members, progress?.state]); }, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]);
return ( return (
<MemberDetailDialog <MemberDetailDialog
@ -788,6 +795,10 @@ export const TeamDetailView = ({
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null); const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null); const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null); const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
const [selectedMemberView, setSelectedMemberView] = useState<{
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
} | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({}); const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({ const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false, open: false,
@ -858,10 +869,21 @@ export const TeamDetailView = ({
setSendDialogOpen(true); setSendDialogOpen(true);
}; };
const onOpenProfile = (e: Event) => { const onOpenProfile = (e: Event) => {
const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; const {
teamName: tn,
memberName,
initialTab,
initialActivityFilter,
} = (e as CustomEvent).detail ?? {};
if (tn !== teamName || !data) return; if (tn !== teamName || !data) return;
const member = data.members.find((m: { name: string }) => m.name === memberName); const member = data.members.find((m: { name: string }) => m.name === memberName);
if (member) setSelectedMember(member); if (member) {
setSelectedMember(member);
setSelectedMemberView({
initialTab,
initialActivityFilter,
});
}
}; };
const onCreateTask = (e: Event) => { const onCreateTask = (e: Event) => {
const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; const { teamName: tn, owner } = (e as CustomEvent).detail ?? {};
@ -1510,6 +1532,12 @@ export const TeamDetailView = ({
const handleSelectMember = useCallback((member: ResolvedTeamMember) => { const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
setSelectedMember(member); setSelectedMember(member);
setSelectedMemberView(null);
}, []);
const closeSelectedMemberDialog = useCallback(() => {
setSelectedMember(null);
setSelectedMemberView(null);
}, []); }, []);
const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => {
@ -1612,6 +1640,7 @@ export const TeamDetailView = ({
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
if (member) { if (member) {
setSelectedMember(member); setSelectedMember(member);
setSelectedMemberView(null);
} }
useStore.getState().closeMemberProfile(); useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, membersWithLiveBranches]); }, [pendingMemberProfile, membersWithLiveBranches]);
@ -2456,14 +2485,17 @@ export const TeamDetailView = ({
open={selectedMember !== null} open={selectedMember !== null}
member={selectedMember} member={selectedMember}
teamName={teamName} teamName={teamName}
members={membersWithLiveBranches}
tasks={data.tasks} tasks={data.tasks}
messages={data.messages} messages={data.messages}
initialTab={selectedMemberView?.initialTab}
initialActivityFilter={selectedMemberView?.initialActivityFilter}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
onClose={() => setSelectedMember(null)} onClose={closeSelectedMemberDialog}
onSendMessage={() => { onSendMessage={() => {
const name = selectedMember?.name ?? ''; const name = selectedMember?.name ?? '';
setSelectedMember(null); closeSelectedMemberDialog();
setSendDialogRecipient(name || undefined); setSendDialogRecipient(name || undefined);
setSendDialogDefaultText(undefined); setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined); setSendDialogDefaultChip(undefined);
@ -2472,11 +2504,11 @@ export const TeamDetailView = ({
}} }}
onAssignTask={() => { onAssignTask={() => {
const name = selectedMember?.name ?? ''; const name = selectedMember?.name ?? '';
setSelectedMember(null); closeSelectedMemberDialog();
openCreateTaskDialog('', '', name); openCreateTaskDialog('', '', name);
}} }}
onTaskClick={(task) => { onTaskClick={(task) => {
setSelectedMember(null); closeSelectedMemberDialog();
setSelectedTask(task); setSelectedTask(task);
}} }}
onUpdateRole={async (memberName, role) => { onUpdateRole={async (memberName, role) => {
@ -2501,7 +2533,7 @@ export const TeamDetailView = ({
setRemoveMemberConfirm(name); setRemoveMemberConfirm(name);
}} }}
onViewMemberChanges={(memberName, filePath) => { onViewMemberChanges={(memberName, filePath) => {
setSelectedMember(null); closeSelectedMemberDialog();
setReviewDialogState({ setReviewDialogState({
open: true, open: true,
mode: 'agent', mode: 'agent',
@ -2596,7 +2628,7 @@ export const TeamDetailView = ({
onClick={() => { onClick={() => {
const name = removeMemberConfirm; const name = removeMemberConfirm;
setRemoveMemberConfirm(null); setRemoveMemberConfirm(null);
setSelectedMember(null); closeSelectedMemberDialog();
if (name) void removeMember(teamName, name); if (name) void removeMember(teamName, name);
}} }}
> >
@ -2803,10 +2835,14 @@ export const TeamDetailView = ({
const task = data.tasks.find((t) => t.id === taskId); const task = data.tasks.find((t) => t.id === taskId);
if (task) setSelectedTask(task); if (task) setSelectedTask(task);
}} }}
onOpenMemberProfile={(memberName) => { onOpenMemberProfile={(memberName, options) => {
const member = data.members.find((m) => m.name === memberName); const member = data.members.find((m) => m.name === memberName);
if (member) { if (member) {
setSelectedMember(member); setSelectedMember(member);
setSelectedMemberView({
initialTab: options?.initialTab,
initialActivityFilter: options?.initialActivityFilter,
});
} }
}} }}
/> />

View file

@ -575,6 +575,50 @@ function renderInlineBoldSummary(
}); });
} }
function TaskRecipientBadge({
taskId,
displayId,
teamName,
onTaskIdClick,
}: {
taskId: string;
displayId: string;
teamName?: string;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element {
const content = (
<span
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: 'rgba(96, 165, 250, 0.14)',
color: '#60a5fa',
border: '1px solid rgba(96, 165, 250, 0.3)',
}}
>
{displayId}
</span>
);
if (!onTaskIdClick) {
return content;
}
return (
<TaskTooltip taskId={taskId} teamName={teamName}>
<button
type="button"
className="inline-flex rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onTaskIdClick(taskId);
}}
>
{content}
</button>
</TaskTooltip>
);
}
export const ActivityItem = memo( export const ActivityItem = memo(
function ActivityItem({ function ActivityItem({
message, message,
@ -784,6 +828,11 @@ export const ActivityItem = memo(
structured, structured,
]); ]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
const commentTaskRef =
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
const commentTaskDisplayId =
commentTaskRef?.displayId ??
(commentTaskRef?.taskId ? `#${commentTaskRef.taskId.slice(0, 6)}` : null);
// Permission request status icon (check/x/clock) // Permission request status icon (check/x/clock)
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
@ -902,6 +951,10 @@ export const ActivityItem = memo(
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{systemLabel} {systemLabel}
</span> </span>
) : commentTaskRef ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
Comment
</span>
) : isSlashCommandResult && message.commandOutput ? ( ) : isSlashCommandResult && message.commandOutput ? (
<span <span
className={[ className={[
@ -943,7 +996,17 @@ export const ActivityItem = memo(
) : null; ) : null;
const recipientBadge = const recipientBadge =
message.to && message.to !== message.from ? ( commentTaskRef && commentTaskDisplayId ? (
<>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<TaskRecipientBadge
taskId={commentTaskRef.taskId}
displayId={commentTaskDisplayId}
teamName={commentTaskRef.teamName}
onTaskIdClick={onTaskIdClick}
/>
</>
) : message.to && message.to !== message.from ? (
<> <>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" /> <MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
{crossTeamTarget ? ( {crossTeamTarget ? (

View file

@ -1,18 +1,20 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats'; import { MemberDetailStats } from './MemberDetailStats';
import { MemberLogsTab } from './MemberLogsTab'; import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab'; import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab'; import { MemberTasksTab } from './MemberTasksTab';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import type { import type {
InboxMessage, InboxMessage,
@ -26,8 +28,11 @@ interface MemberDetailDialogProps {
open: boolean; open: boolean;
member: ResolvedTeamMember | null; member: ResolvedTeamMember | null;
teamName: string; teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
messages: InboxMessage[]; messages: InboxMessage[];
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
@ -47,8 +52,11 @@ export const MemberDetailDialog = ({
open, open,
member, member,
teamName, teamName,
members,
tasks, tasks,
messages, messages,
initialTab = 'tasks',
initialActivityFilter = 'all',
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
isLaunchSettling, isLaunchSettling,
@ -73,6 +81,27 @@ export const MemberDetailDialog = ({
[messages, member] [messages, member]
); );
const memberMessages = seedMemberMessages; const memberMessages = seedMemberMessages;
const memberActivityCount = useMemo(() => {
if (!member) {
return 0;
}
const leadId = `lead:${teamName}`;
const leadName =
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
const ownerNodeId = member.name === leadName ? leadId : `member:${teamName}:${member.name}`;
const entries = buildInlineActivityEntries({
data: {
members,
tasks,
messages: memberMessages,
},
teamName,
leadId,
leadName,
ownerNodeIds: new Set([leadId, ownerNodeId]),
});
return (entries.get(ownerNodeId) ?? []).length;
}, [member, memberMessages, members, tasks, teamName]);
const inProgressTasks = useMemo( const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length, () => memberTasks.filter((t) => t.status === 'in_progress').length,
@ -84,7 +113,14 @@ export const MemberDetailDialog = ({
[memberTasks] [memberTasks]
); );
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks'); const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
useEffect(() => {
if (!open || !member) {
return;
}
setActiveTab(initialTab);
}, [initialTab, member, open]);
const { const {
stats: memberStats, stats: memberStats,
@ -122,7 +158,7 @@ export const MemberDetailDialog = ({
totalTasks={memberTasks.length} totalTasks={memberTasks.length}
inProgressTasks={inProgressTasks} inProgressTasks={inProgressTasks}
completedTasks={completedTasks} completedTasks={completedTasks}
messageCount={memberMessages.length} activityCount={memberActivityCount}
totalTokens={totalTokens} totalTokens={totalTokens}
statsLoading={statsLoading} statsLoading={statsLoading}
statsComputedAt={memberStats?.computedAt} statsComputedAt={memberStats?.computedAt}
@ -144,11 +180,11 @@ export const MemberDetailDialog = ({
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="messages" className="flex-1 gap-1.5"> <TabsTrigger value="activity" className="flex-1 gap-1.5">
Messages Activity
{memberMessages.length > 0 && ( {memberActivityCount > 0 && (
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]"> <span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
{memberMessages.length} {memberActivityCount}
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
@ -164,11 +200,15 @@ export const MemberDetailDialog = ({
<TabsContent value="tasks"> <TabsContent value="tasks">
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} /> <MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
</TabsContent> </TabsContent>
<TabsContent value="messages"> <TabsContent value="activity">
<MemberMessagesTab <MemberMessagesTab
messages={memberMessages} messages={memberMessages}
teamName={teamName} teamName={teamName}
memberName={member.name} memberName={member.name}
members={members}
tasks={tasks}
initialFilter={initialActivityFilter}
onTaskClick={onTaskClick}
/> />
</TabsContent> </TabsContent>
<TabsContent value="stats"> <TabsContent value="stats">

View file

@ -1,12 +1,12 @@
import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters'; import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters';
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs'; import type { MemberDetailTab } from './memberDetailTypes';
interface MemberDetailStatsProps { interface MemberDetailStatsProps {
totalTasks: number; totalTasks: number;
inProgressTasks: number; inProgressTasks: number;
completedTasks: number; completedTasks: number;
messageCount: number; activityCount: number;
totalTokens: number | null; totalTokens: number | null;
statsLoading?: boolean; statsLoading?: boolean;
statsComputedAt?: string; statsComputedAt?: string;
@ -51,7 +51,7 @@ export const MemberDetailStats = ({
totalTasks, totalTasks,
inProgressTasks, inProgressTasks,
completedTasks, completedTasks,
messageCount, activityCount,
totalTokens, totalTokens,
statsLoading, statsLoading,
statsComputedAt, statsComputedAt,
@ -79,9 +79,9 @@ export const MemberDetailStats = ({
onClick={onTabChange ? () => onTabChange('tasks') : undefined} onClick={onTabChange ? () => onTabChange('tasks') : undefined}
/> />
<StatBlock <StatBlock
label="Messages" label="Activity"
value={messageCount} value={activityCount}
onClick={onTabChange ? () => onTabChange('messages') : undefined} onClick={onTabChange ? () => onTabChange('activity') : undefined}
/> />
<StatBlock <StatBlock
label="Tokens" label="Tokens"

View file

@ -1,34 +1,73 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; 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 { Button } from '@renderer/components/ui/button';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { isLeadMember } from '@shared/utils/leadDetection';
import { ActivityItem } from '../activity/ActivityItem'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type { MemberActivityFilter } from './memberDetailTypes';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberMessagesTabProps { interface MemberMessagesTabProps {
messages: InboxMessage[]; messages: InboxMessage[];
teamName: string; teamName: string;
memberName: string; memberName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
initialFilter?: MemberActivityFilter;
onCreateTask?: (subject: string, description: string) => void; onCreateTask?: (subject: string, description: string) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void;
} }
const MAX_MESSAGES = 100; const MAX_MESSAGES = 100;
const MEMBER_MESSAGES_PAGE_SIZE = 50; const MEMBER_MESSAGES_PAGE_SIZE = 50;
const FILTER_OPTIONS: ReadonlyArray<{ value: MemberActivityFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'messages', label: 'Messages' },
{ value: 'comments', label: 'Comments' },
];
export const MemberMessagesTab = ({ export const MemberMessagesTab = ({
messages, messages,
teamName, teamName,
memberName, memberName,
members,
tasks,
initialFilter = 'all',
onCreateTask, onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => { }: MemberMessagesTabProps): React.JSX.Element => {
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]); const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -39,7 +78,9 @@ export const MemberMessagesTab = ({
void (async () => { void (async () => {
try { try {
const page = await api.teams.getMessagesPage(teamName, { limit: MEMBER_MESSAGES_PAGE_SIZE }); const page = await api.teams.getMessagesPage(teamName, {
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
if (cancelled) return; if (cancelled) return;
const memberPageMessages = page.messages.filter( const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName (message) => message.from === memberName || message.to === memberName
@ -89,45 +130,185 @@ export const MemberMessagesTab = ({
[messages, pagedMessages] [messages, pagedMessages]
); );
const displayMessages = useMemo( const filteredMessages = useMemo(
() => () =>
filterTeamMessages(effectiveMessages, { filterTeamMessages(effectiveMessages, {
timeWindow: null, timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true }, filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '', searchQuery: '',
}).slice(0, MAX_MESSAGES), }),
[effectiveMessages] [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 = loading const emptyStateText = loading
? 'Loading messages...' ? 'Loading activity...'
: hasMore : activityFilter === 'comments'
? 'No loaded messages for this member yet' ? 'No comments for this member'
: 'No messages with this member'; : activityFilter === 'messages'
? hasMore
? 'No loaded messages for this member yet'
: 'No messages with this member'
: 'No activity with this member';
return ( return (
<div className="max-h-[320px] space-y-2 overflow-y-auto"> <div className="space-y-3">
{displayMessages.length > 0 ? ( <div className="flex flex-wrap items-center gap-2">
displayMessages.map((msg, idx) => ( {FILTER_OPTIONS.map((option) => {
<ActivityItem const isActive = activityFilter === option.value;
key={msg.messageId ?? idx} return (
message={msg} <button
teamName={teamName} key={option.value}
onCreateTask={onCreateTask} type="button"
/> className={[
)) 'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
) : ( isActive
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]"> ? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] text-[var(--color-text)]'
{emptyStateText} : 'border-[var(--color-border)] bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]',
</div> ].join(' ')}
)} onClick={() => setActivityFilter(option.value)}
{hasMore && ( >
<div className="flex justify-center pt-2"> {option.label}
<Button variant="ghost" size="sm" className="text-xs" disabled={loading} onClick={() => void loadOlderMessages()}> </button>
{loading ? 'Loading...' : 'Load older messages'} );
</Button> })}
</div> </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>
)}
{hasMore && activityFilter !== 'comments' && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
disabled={loading}
onClick={() => void loadOlderMessages()}
>
{loading ? 'Loading...' : '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> </div>
); );
}; };

View file

@ -0,0 +1,3 @@
export type MemberDetailTab = 'tasks' | 'activity' | 'stats' | 'logs';
export type MemberActivityFilter = 'all' | 'messages' | 'comments';

View file

@ -20,6 +20,10 @@ import {
} from '../utils/buildInlineActivityEntries'; } from '../utils/buildInlineActivityEntries';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
import type { ResolvedTeamMember } from '@shared/types/team'; import type { ResolvedTeamMember } from '@shared/types/team';
@ -32,7 +36,13 @@ interface GraphActivityHudProps {
focusNodeIds: ReadonlySet<string> | null; focusNodeIds: ReadonlySet<string> | null;
enabled?: boolean; enabled?: boolean;
onOpenTaskDetail?: (taskId: string) => void; onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (memberName: string) => void; onOpenMemberProfile?: (
memberName: string,
options?: {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
) => void;
} }
export function GraphActivityHud({ export function GraphActivityHud({
@ -186,6 +196,19 @@ export function GraphActivityHud({
[onOpenMemberProfile] [onOpenMemberProfile]
); );
const handleOpenOwnerActivity = useCallback(
(node: GraphNode & { kind: 'lead' | 'member' }) => {
if (node.domainRef.kind !== 'lead' && node.domainRef.kind !== 'member') {
return;
}
onOpenMemberProfile?.(node.domainRef.memberName, {
initialTab: 'activity',
initialActivityFilter: 'all',
});
},
[onOpenMemberProfile]
);
if (!enabled || !teamData || visibleLanes.length === 0) { if (!enabled || !teamData || visibleLanes.length === 0) {
return null; return null;
} }
@ -250,9 +273,13 @@ export function GraphActivityHud({
})} })}
{lane.overflowCount > 0 ? ( {lane.overflowCount > 0 ? (
<div className="rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300"> <button
type="button"
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
+{lane.overflowCount} more +{lane.overflowCount} more
</div> </button>
) : null} ) : null}
</div> </div>
</div> </div>

View file

@ -7,6 +7,10 @@ import { useCallback, useMemo } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
@ -23,7 +27,13 @@ export interface TeamGraphOverlayProps {
onPinAsTab?: () => void; onPinAsTab?: () => void;
onSendMessage?: (memberName: string) => void; onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void; onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (memberName: string) => void; onOpenMemberProfile?: (
memberName: string,
options?: {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
) => void;
} }
export const TeamGraphOverlay = ({ export const TeamGraphOverlay = ({

View file

@ -7,6 +7,10 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
@ -27,6 +31,11 @@ export interface TeamGraphTabProps {
isPaneFocused?: boolean; isPaneFocused?: boolean;
} }
type OpenProfileOptions = {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
};
export const TeamGraphTab = ({ export const TeamGraphTab = ({
teamName, teamName,
isActive = true, isActive = true,
@ -53,9 +62,11 @@ export const TeamGraphTab = ({
[teamName] [teamName]
); );
const dispatchOpenProfile = useCallback( const dispatchOpenProfile = useCallback(
(memberName: string) => (memberName: string, options?: OpenProfileOptions) =>
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('graph:open-profile', { detail: { teamName, memberName } }) new CustomEvent('graph:open-profile', {
detail: { teamName, memberName, ...options },
})
), ),
[teamName] [teamName]
); );
@ -105,7 +116,12 @@ export const TeamGraphTab = ({
), ),
onSendMessage: dispatchSendMessage, onSendMessage: dispatchSendMessage,
onOpenTaskDetail: dispatchOpenTask, onOpenTaskDetail: dispatchOpenTask,
onOpenMemberProfile: dispatchOpenProfile, onOpenMemberProfile: useCallback(
(memberName: string) => {
dispatchOpenProfile(memberName);
},
[dispatchOpenProfile]
),
}; };
return ( return (

View file

@ -10,8 +10,8 @@ import type {
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
TaskRef, TaskRef,
TeamData,
TeamTaskWithKanban, TeamTaskWithKanban,
ResolvedTeamMember,
} from '@shared/types/team'; } from '@shared/types/team';
export interface InlineActivityEntry { export interface InlineActivityEntry {
@ -20,15 +20,24 @@ export interface InlineActivityEntry {
message: InboxMessage; message: InboxMessage;
} }
export interface ActivityEntrySourceData {
members: ResolvedTeamMember[];
tasks: readonly TeamTaskWithKanban[];
messages: readonly InboxMessage[];
}
export interface BuildInlineActivityEntriesArgs { export interface BuildInlineActivityEntriesArgs {
data: TeamData; data: ActivityEntrySourceData;
teamName: string; teamName: string;
leadId: string; leadId: string;
leadName: string; leadName: string;
ownerNodeIds: ReadonlySet<string>; ownerNodeIds: ReadonlySet<string>;
} }
export function getGraphLeadMemberName(data: TeamData, teamName: string): string { export function getGraphLeadMemberName(
data: Pick<ActivityEntrySourceData, 'members'>,
teamName: string
): string {
return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`;
} }

View file

@ -245,4 +245,38 @@ describe('ActivityItem legacy system message fallback', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('renders task comments as comments addressed to a task, not a participant', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'jack',
to: 'team-lead',
text: 'Короткий отчёт по contributor/internal implementation navigation',
summary: '#8fdd6803 Короткий отчёт по contributor/internal implementation navigation',
timestamp: new Date('2026-04-13T13:35:00.000Z').toISOString(),
read: true,
source: 'inbox',
messageKind: 'task_comment_notification',
taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }],
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('Comment');
expect(host.textContent).toContain('jack');
expect(host.textContent).toContain('#8fdd6803');
expect(host.textContent).not.toContain('team-lead');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -0,0 +1,162 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
const getMessagesPage = vi.fn();
vi.mock('@renderer/api', () => ({
api: {
teams: {
getMessagesPage: (...args: unknown[]) => getMessagesPage(...args),
},
},
}));
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
ActivityItem: ({ message }: { message: InboxMessage }) =>
React.createElement(
'div',
{
'data-testid': 'activity-item',
'data-kind': message.messageKind ?? 'message',
},
`${message.messageKind ?? 'message'}:${message.summary ?? message.text ?? ''}`
),
}));
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
MessageExpandDialog: () => null,
}));
vi.mock('@renderer/hooks/useTeamMessagesRead', () => ({
useTeamMessagesRead: () => ({
readSet: new Set<string>(),
markRead: vi.fn(),
markAllRead: vi.fn(),
}),
}));
describe('MemberMessagesTab', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
getMessagesPage.mockResolvedValue({
messages: [],
nextCursor: null,
hasMore: false,
});
});
afterEach(() => {
document.body.innerHTML = '';
getMessagesPage.mockReset();
});
it('shows both messages and comments by default and filters them separately', async () => {
const members: ResolvedTeamMember[] = [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
];
const messages: InboxMessage[] = [
{
from: 'team-lead',
to: 'jack',
text: 'New task assigned',
summary: 'New task assigned',
timestamp: '2026-04-13T13:34:00.000Z',
read: false,
messageId: 'msg-1',
},
];
const tasks: TeamTaskWithKanban[] = [
{
id: 'task-1',
displayId: '#8fdd6803',
subject: 'Review contributor notes',
owner: 'jack',
status: 'in_progress',
comments: [
{
id: 'comment-1',
author: 'jack',
text: 'Короткий отчёт по contributor pass',
createdAt: '2026-04-13T13:35:00.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
];
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberMessagesTab, {
messages,
teamName: 'demo-team',
memberName: 'jack',
members,
tasks,
})
);
await Promise.resolve();
});
const getRenderedKinds = () =>
Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) =>
node.getAttribute('data-kind')
);
expect(getRenderedKinds()).toEqual(['task_comment_notification', 'message']);
const messagesButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Messages'
);
expect(messagesButton).not.toBeUndefined();
await act(async () => {
messagesButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(getRenderedKinds()).toEqual(['message']);
const commentsButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Comments'
);
expect(commentsButton).not.toBeUndefined();
await act(async () => {
commentsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(getRenderedKinds()).toEqual(['task_comment_notification']);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,218 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphActivityHud } from '@renderer/features/agent-graph/ui/GraphActivityHud';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { InboxMessage } from '@shared/types/team';
const teamState = {
selectedTeamName: 'demo-team',
selectedTeamData: {
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
messages: [],
},
teamDataCacheByName: {
'demo-team': {
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
messages: [],
},
} as Record<string, { members: Array<Record<string, unknown>>; tasks: unknown[]; messages: unknown[] }>,
teams: [],
};
const buildInlineActivityEntries = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof teamState) => unknown) => selector(teamState),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
teamState.teamDataCacheByName[teamName] ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}));
vi.mock('@renderer/hooks/useTeamMessagesRead', () => ({
useTeamMessagesRead: () => ({
readSet: new Set<string>(),
markRead: vi.fn(),
markAllRead: vi.fn(),
}),
}));
vi.mock('@renderer/hooks/useStableTeamMentionMeta', () => ({
useStableTeamMentionMeta: () => ({
teamNames: [],
teamColorByName: new Map(),
}),
}));
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
ActivityItem: ({ message }: { message: InboxMessage }) =>
React.createElement('div', { 'data-testid': 'activity-item' }, message.summary ?? message.text),
}));
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
MessageExpandDialog: () => null,
}));
vi.mock('@renderer/components/team/activity/activityMessageContext', () => ({
buildMessageContext: () => ({
colorMap: new Map(),
localMemberNames: new Set<string>(),
memberInfo: new Map(),
}),
resolveMessageRenderProps: () => ({}),
}));
vi.mock('@renderer/features/agent-graph/utils/buildInlineActivityEntries', () => ({
buildInlineActivityEntries: (...args: unknown[]) => buildInlineActivityEntries(...args),
getGraphLeadMemberName: () => 'team-lead',
}));
describe('GraphActivityHud', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
buildInlineActivityEntries.mockReset();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('opens the member profile on the Activity tab when +N more is clicked', async () => {
const visibleMessages: InboxMessage[] = [
{
from: 'team-lead',
to: 'jack',
text: 'First',
summary: 'First',
timestamp: '2026-04-13T13:34:00.000Z',
read: false,
messageId: 'msg-1',
},
{
from: 'team-lead',
to: 'jack',
text: 'Second',
summary: 'Second',
timestamp: '2026-04-13T13:35:00.000Z',
read: false,
messageId: 'msg-2',
},
{
from: 'team-lead',
to: 'jack',
text: 'Third',
summary: 'Third',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-3',
},
];
buildInlineActivityEntries.mockReturnValue(
new Map([
[
'member:demo-team:jack',
visibleMessages.map((message, index) => ({
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id: `item-${index + 1}`,
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
})),
],
])
);
const node: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-1',
kind: 'inbox_message',
timestamp: '2026-04-13T13:36:00.000Z',
title: 'Third',
},
{
id: 'item-2',
kind: 'inbox_message',
timestamp: '2026-04-13T13:35:00.000Z',
title: 'Second',
},
{
id: 'item-3',
kind: 'inbox_message',
timestamp: '2026-04-13T13:34:00.000Z',
title: 'First',
},
{
id: 'item-4',
kind: 'inbox_message',
timestamp: '2026-04-13T13:33:00.000Z',
title: 'Older hidden',
},
],
activityOverflowCount: 1,
};
const onOpenMemberProfile = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
focusNodeIds: null,
onOpenMemberProfile,
})
);
await Promise.resolve();
});
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('+1 more')
);
expect(moreButton).not.toBeUndefined();
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenMemberProfile).toHaveBeenCalledWith('jack', {
initialTab: 'activity',
initialActivityFilter: 'all',
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});