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 { 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,
});
}
}}
/>

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(
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 ? (

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 { 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">

View file

@ -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"

View file

@ -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>
);
};

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';
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>

View file

@ -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 = ({

View file

@ -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 (

View file

@ -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`;
}

View file

@ -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();
});
});
});

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();
});
});
});