feat(member-details): enhance member detail dialog with activity filtering and initial tab state
This commit is contained in:
parent
5b328a0f8a
commit
6fbf8518dc
13 changed files with 880 additions and 81 deletions
|
|
@ -70,6 +70,7 @@ import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
|||
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
|
||||
import { TrashDialog } from './kanban/TrashDialog';
|
||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
|
||||
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||
|
|
@ -737,29 +738,35 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
member,
|
||||
...props
|
||||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||
const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } =
|
||||
useStore(
|
||||
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 {
|
||||
leadActivity,
|
||||
progress,
|
||||
members: launchMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
} = useStore(
|
||||
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(() => {
|
||||
if (progress?.state !== 'ready') {
|
||||
return false;
|
||||
}
|
||||
return getLaunchJoinState(
|
||||
getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
members: launchMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
})
|
||||
).hasMembersStillJoining;
|
||||
}, [memberSpawnSnapshot, memberSpawnStatuses, members, progress?.state]);
|
||||
}, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]);
|
||||
|
||||
return (
|
||||
<MemberDetailDialog
|
||||
|
|
@ -788,6 +795,10 @@ export const TeamDetailView = ({
|
|||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | 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 [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||
open: false,
|
||||
|
|
@ -858,10 +869,21 @@ export const TeamDetailView = ({
|
|||
setSendDialogOpen(true);
|
||||
};
|
||||
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;
|
||||
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 { teamName: tn, owner } = (e as CustomEvent).detail ?? {};
|
||||
|
|
@ -1510,6 +1532,12 @@ export const TeamDetailView = ({
|
|||
|
||||
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView(null);
|
||||
}, []);
|
||||
|
||||
const closeSelectedMemberDialog = useCallback(() => {
|
||||
setSelectedMember(null);
|
||||
setSelectedMemberView(null);
|
||||
}, []);
|
||||
|
||||
const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => {
|
||||
|
|
@ -1612,6 +1640,7 @@ export const TeamDetailView = ({
|
|||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView(null);
|
||||
}
|
||||
useStore.getState().closeMemberProfile();
|
||||
}, [pendingMemberProfile, membersWithLiveBranches]);
|
||||
|
|
@ -2456,14 +2485,17 @@ export const TeamDetailView = ({
|
|||
open={selectedMember !== null}
|
||||
member={selectedMember}
|
||||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
initialTab={selectedMemberView?.initialTab}
|
||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
onClose={() => setSelectedMember(null)}
|
||||
onClose={closeSelectedMemberDialog}
|
||||
onSendMessage={() => {
|
||||
const name = selectedMember?.name ?? '';
|
||||
setSelectedMember(null);
|
||||
closeSelectedMemberDialog();
|
||||
setSendDialogRecipient(name || undefined);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
|
|
@ -2472,11 +2504,11 @@ export const TeamDetailView = ({
|
|||
}}
|
||||
onAssignTask={() => {
|
||||
const name = selectedMember?.name ?? '';
|
||||
setSelectedMember(null);
|
||||
closeSelectedMemberDialog();
|
||||
openCreateTaskDialog('', '', name);
|
||||
}}
|
||||
onTaskClick={(task) => {
|
||||
setSelectedMember(null);
|
||||
closeSelectedMemberDialog();
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
onUpdateRole={async (memberName, role) => {
|
||||
|
|
@ -2501,7 +2533,7 @@ export const TeamDetailView = ({
|
|||
setRemoveMemberConfirm(name);
|
||||
}}
|
||||
onViewMemberChanges={(memberName, filePath) => {
|
||||
setSelectedMember(null);
|
||||
closeSelectedMemberDialog();
|
||||
setReviewDialogState({
|
||||
open: true,
|
||||
mode: 'agent',
|
||||
|
|
@ -2596,7 +2628,7 @@ export const TeamDetailView = ({
|
|||
onClick={() => {
|
||||
const name = removeMemberConfirm;
|
||||
setRemoveMemberConfirm(null);
|
||||
setSelectedMember(null);
|
||||
closeSelectedMemberDialog();
|
||||
if (name) void removeMember(teamName, name);
|
||||
}}
|
||||
>
|
||||
|
|
@ -2803,10 +2835,14 @@ export const TeamDetailView = ({
|
|||
const task = data.tasks.find((t) => t.id === taskId);
|
||||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
onOpenMemberProfile={(memberName) => {
|
||||
onOpenMemberProfile={(memberName, options) => {
|
||||
const member = data.members.find((m) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
initialTab: options?.initialTab,
|
||||
initialActivityFilter: options?.initialActivityFilter,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
function ActivityItem({
|
||||
message,
|
||||
|
|
@ -784,6 +828,11 @@ export const ActivityItem = memo(
|
|||
structured,
|
||||
]);
|
||||
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)
|
||||
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 }}>
|
||||
{systemLabel}
|
||||
</span>
|
||||
) : commentTaskRef ? (
|
||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
Comment
|
||||
</span>
|
||||
) : isSlashCommandResult && message.commandOutput ? (
|
||||
<span
|
||||
className={[
|
||||
|
|
@ -943,7 +996,17 @@ export const ActivityItem = memo(
|
|||
) : null;
|
||||
|
||||
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" />
|
||||
{crossTeamTarget ? (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
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 { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
|
||||
|
||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats';
|
||||
import { MemberDetailStats } from './MemberDetailStats';
|
||||
import { MemberLogsTab } from './MemberLogsTab';
|
||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
import { MemberTasksTab } from './MemberTasksTab';
|
||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
|
|
@ -26,8 +28,11 @@ interface MemberDetailDialogProps {
|
|||
open: boolean;
|
||||
member: ResolvedTeamMember | null;
|
||||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
tasks: TeamTaskWithKanban[];
|
||||
messages: InboxMessage[];
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
isLaunchSettling?: boolean;
|
||||
|
|
@ -47,8 +52,11 @@ export const MemberDetailDialog = ({
|
|||
open,
|
||||
member,
|
||||
teamName,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
initialTab = 'tasks',
|
||||
initialActivityFilter = 'all',
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
isLaunchSettling,
|
||||
|
|
@ -73,6 +81,27 @@ export const MemberDetailDialog = ({
|
|||
[messages, member]
|
||||
);
|
||||
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(
|
||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
||||
|
|
@ -84,7 +113,14 @@ export const MemberDetailDialog = ({
|
|||
[memberTasks]
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
|
||||
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !member) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(initialTab);
|
||||
}, [initialTab, member, open]);
|
||||
|
||||
const {
|
||||
stats: memberStats,
|
||||
|
|
@ -122,7 +158,7 @@ export const MemberDetailDialog = ({
|
|||
totalTasks={memberTasks.length}
|
||||
inProgressTasks={inProgressTasks}
|
||||
completedTasks={completedTasks}
|
||||
messageCount={memberMessages.length}
|
||||
activityCount={memberActivityCount}
|
||||
totalTokens={totalTokens}
|
||||
statsLoading={statsLoading}
|
||||
statsComputedAt={memberStats?.computedAt}
|
||||
|
|
@ -144,11 +180,11 @@ export const MemberDetailDialog = ({
|
|||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="messages" className="flex-1 gap-1.5">
|
||||
Messages
|
||||
{memberMessages.length > 0 && (
|
||||
<TabsTrigger value="activity" className="flex-1 gap-1.5">
|
||||
Activity
|
||||
{memberActivityCount > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
|
||||
{memberMessages.length}
|
||||
{memberActivityCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
|
@ -164,11 +200,15 @@ export const MemberDetailDialog = ({
|
|||
<TabsContent value="tasks">
|
||||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
</TabsContent>
|
||||
<TabsContent value="messages">
|
||||
<TabsContent value="activity">
|
||||
<MemberMessagesTab
|
||||
messages={memberMessages}
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
members={members}
|
||||
tasks={tasks}
|
||||
initialFilter={initialActivityFilter}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters';
|
||||
|
||||
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
|
||||
import type { MemberDetailTab } from './memberDetailTypes';
|
||||
|
||||
interface MemberDetailStatsProps {
|
||||
totalTasks: number;
|
||||
inProgressTasks: number;
|
||||
completedTasks: number;
|
||||
messageCount: number;
|
||||
activityCount: number;
|
||||
totalTokens: number | null;
|
||||
statsLoading?: boolean;
|
||||
statsComputedAt?: string;
|
||||
|
|
@ -51,7 +51,7 @@ export const MemberDetailStats = ({
|
|||
totalTasks,
|
||||
inProgressTasks,
|
||||
completedTasks,
|
||||
messageCount,
|
||||
activityCount,
|
||||
totalTokens,
|
||||
statsLoading,
|
||||
statsComputedAt,
|
||||
|
|
@ -79,9 +79,9 @@ export const MemberDetailStats = ({
|
|||
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Messages"
|
||||
value={messageCount}
|
||||
onClick={onTabChange ? () => onTabChange('messages') : undefined}
|
||||
label="Activity"
|
||||
value={activityCount}
|
||||
onClick={onTabChange ? () => onTabChange('activity') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Tokens"
|
||||
|
|
|
|||
|
|
@ -1,34 +1,73 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
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 { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import { ActivityItem } from '../activity/ActivityItem';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||
import type { MemberActivityFilter } from './memberDetailTypes';
|
||||
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: ReadonlyArray<{ 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 [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(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -39,7 +78,9 @@ export const MemberMessagesTab = ({
|
|||
|
||||
void (async () => {
|
||||
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;
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
|
|
@ -89,45 +130,185 @@ export const MemberMessagesTab = ({
|
|||
[messages, pagedMessages]
|
||||
);
|
||||
|
||||
const displayMessages = useMemo(
|
||||
const filteredMessages = useMemo(
|
||||
() =>
|
||||
filterTeamMessages(effectiveMessages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
}).slice(0, MAX_MESSAGES),
|
||||
}),
|
||||
[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
|
||||
? 'Loading messages...'
|
||||
: hasMore
|
||||
? 'No loaded messages for this member yet'
|
||||
: 'No messages with this member';
|
||||
? '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';
|
||||
|
||||
return (
|
||||
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
||||
{displayMessages.length > 0 ? (
|
||||
displayMessages.map((msg, idx) => (
|
||||
<ActivityItem
|
||||
key={msg.messageId ?? idx}
|
||||
message={msg}
|
||||
teamName={teamName}
|
||||
onCreateTask={onCreateTask}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<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 && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export type MemberDetailTab = 'tasks' | 'activity' | 'stats' | 'logs';
|
||||
|
||||
export type MemberActivityFilter = 'all' | 'messages' | 'comments';
|
||||
|
|
@ -20,6 +20,10 @@ import {
|
|||
} from '../utils/buildInlineActivityEntries';
|
||||
|
||||
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 { ResolvedTeamMember } from '@shared/types/team';
|
||||
|
||||
|
|
@ -32,7 +36,13 @@ interface GraphActivityHudProps {
|
|||
focusNodeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (memberName: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function GraphActivityHud({
|
||||
|
|
@ -186,6 +196,19 @@ export function GraphActivityHud({
|
|||
[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) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -250,9 +273,13 @@ export function GraphActivityHud({
|
|||
})}
|
||||
|
||||
{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
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { useCallback, useMemo } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||
|
||||
|
|
@ -23,7 +27,13 @@ export interface TeamGraphOverlayProps {
|
|||
onPinAsTab?: () => void;
|
||||
onSendMessage?: (memberName: string) => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (memberName: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const TeamGraphOverlay = ({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||
|
||||
|
|
@ -27,6 +31,11 @@ export interface TeamGraphTabProps {
|
|||
isPaneFocused?: boolean;
|
||||
}
|
||||
|
||||
type OpenProfileOptions = {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
};
|
||||
|
||||
export const TeamGraphTab = ({
|
||||
teamName,
|
||||
isActive = true,
|
||||
|
|
@ -53,9 +62,11 @@ export const TeamGraphTab = ({
|
|||
[teamName]
|
||||
);
|
||||
const dispatchOpenProfile = useCallback(
|
||||
(memberName: string) =>
|
||||
(memberName: string, options?: OpenProfileOptions) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('graph:open-profile', { detail: { teamName, memberName } })
|
||||
new CustomEvent('graph:open-profile', {
|
||||
detail: { teamName, memberName, ...options },
|
||||
})
|
||||
),
|
||||
[teamName]
|
||||
);
|
||||
|
|
@ -105,7 +116,12 @@ export const TeamGraphTab = ({
|
|||
),
|
||||
onSendMessage: dispatchSendMessage,
|
||||
onOpenTaskDetail: dispatchOpenTask,
|
||||
onOpenMemberProfile: dispatchOpenProfile,
|
||||
onOpenMemberProfile: useCallback(
|
||||
(memberName: string) => {
|
||||
dispatchOpenProfile(memberName);
|
||||
},
|
||||
[dispatchOpenProfile]
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import type {
|
|||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskRef,
|
||||
TeamData,
|
||||
TeamTaskWithKanban,
|
||||
ResolvedTeamMember,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export interface InlineActivityEntry {
|
||||
|
|
@ -20,15 +20,24 @@ export interface InlineActivityEntry {
|
|||
message: InboxMessage;
|
||||
}
|
||||
|
||||
export interface ActivityEntrySourceData {
|
||||
members: ResolvedTeamMember[];
|
||||
tasks: readonly TeamTaskWithKanban[];
|
||||
messages: readonly InboxMessage[];
|
||||
}
|
||||
|
||||
export interface BuildInlineActivityEntriesArgs {
|
||||
data: TeamData;
|
||||
data: ActivityEntrySourceData;
|
||||
teamName: string;
|
||||
leadId: string;
|
||||
leadName: 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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,4 +245,38 @@ describe('ActivityItem legacy system message fallback', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
162
test/renderer/components/team/members/MemberMessagesTab.test.ts
Normal file
162
test/renderer/components/team/members/MemberMessagesTab.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
218
test/renderer/features/agent-graph/GraphActivityHud.test.ts
Normal file
218
test/renderer/features/agent-graph/GraphActivityHud.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue