diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 0916ec7a..573ea3cf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 ( (null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); const [createTaskDialog, setCreateTaskDialog] = useState({ 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, + }); } }} /> diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 23d2e014..a2f825f7 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 = ( + + {displayId} + + ); + + if (!onTaskIdClick) { + return content; + } + + return ( + + + + ); +} + 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( {systemLabel} + ) : commentTaskRef ? ( + + Comment + ) : isSlashCommandResult && message.commandOutput ? ( + + + + ) : message.to && message.to !== message.from ? ( <> {crossTeamTarget ? ( diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 632f641e..ea06cb98 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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('tasks'); + const [activeTab, setActiveTab] = useState(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 = ({ )} - - Messages - {memberMessages.length > 0 && ( + + Activity + {memberActivityCount > 0 && ( - {memberMessages.length} + {memberActivityCount} )} @@ -164,11 +200,15 @@ export const MemberDetailDialog = ({ - + diff --git a/src/renderer/components/team/members/MemberDetailStats.tsx b/src/renderer/components/team/members/MemberDetailStats.tsx index fb358847..f0723476 100644 --- a/src/renderer/components/team/members/MemberDetailStats.tsx +++ b/src/renderer/components/team/members/MemberDetailStats.tsx @@ -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} /> onTabChange('messages') : undefined} + label="Activity" + value={activityCount} + onClick={onTabChange ? () => onTabChange('activity') : undefined} /> 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([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); + const [activityFilter, setActivityFilter] = useState(initialFilter); + const [expandedItem, setExpandedItem] = useState(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(); + 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 ( -
- {displayMessages.length > 0 ? ( - displayMessages.map((msg, idx) => ( - - )) - ) : ( -
- {emptyStateText} -
- )} - {hasMore && ( -
- -
- )} +
+
+ {FILTER_OPTIONS.map((option) => { + const isActive = activityFilter === option.value; + return ( + + ); + })} +
+ +
+ {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 ( +
setExpandedItem(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setExpandedItem(timelineItem); + } + }} + > + +
+ ); + }) + ) : ( +
+ {emptyStateText} +
+ )} + + {hasMore && activityFilter !== 'comments' && ( +
+ +
+ )} +
+ + { + if (!open) { + setExpandedItem(null); + } + }} + teamName={teamName} + members={members} + onTaskIdClick={handleTaskIdClick} + onCreateTaskFromMessage={onCreateTask} + />
); }; diff --git a/src/renderer/components/team/members/memberDetailTypes.ts b/src/renderer/components/team/members/memberDetailTypes.ts new file mode 100644 index 00000000..1d81c1ba --- /dev/null +++ b/src/renderer/components/team/members/memberDetailTypes.ts @@ -0,0 +1,3 @@ +export type MemberDetailTab = 'tasks' | 'activity' | 'stats' | 'logs'; + +export type MemberActivityFilter = 'all' | 'messages' | 'comments'; diff --git a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx index 4c6ef638..377cb1c4 100644 --- a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx +++ b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx @@ -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 | 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 ? ( -
+
+ ) : null}
diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 31ca16e12..580de796 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -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 = ({ diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 4a96cebd..89683dcb 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -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 ( diff --git a/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts b/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts index 24dde1b3..7432e37c 100644 --- a/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts +++ b/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts @@ -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; } -export function getGraphLeadMemberName(data: TeamData, teamName: string): string { +export function getGraphLeadMemberName( + data: Pick, + teamName: string +): string { return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; } diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index e88a274a..482a3926 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberMessagesTab.test.ts b/test/renderer/components/team/members/MemberMessagesTab.test.ts new file mode 100644 index 00000000..03ac8ee0 --- /dev/null +++ b/test/renderer/components/team/members/MemberMessagesTab.test.ts @@ -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(), + 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(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts new file mode 100644 index 00000000..1f772787 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -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>; 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(), + 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(), + 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(); + }); + }); +});