From 4e82102ceb4d590eef5232fa3c84c3682e3df899 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 01:16:01 +0200 Subject: [PATCH] feat: enhance team member resolution and activity components - Introduced a new method to avoid duplicate member names in TeamMemberResolver, ensuring case-insensitive uniqueness. - Updated ActivityItem and ActivityTimeline components to utilize local member names for improved recipient qualification checks. - Added tests to validate the handling of dotted names and deduplication in cross-team messaging scenarios. --- src/main/services/team/TeamMemberResolver.ts | 24 ++++++++--- .../services/team/TeamProvisioningService.ts | 7 ++- .../components/team/activity/ActivityItem.tsx | 15 ++++++- .../team/activity/ActivityTimeline.tsx | 5 +++ .../services/team/TeamMemberResolver.test.ts | 17 ++++++++ ...eamProvisioningServiceLiveMessages.test.ts | 43 +++++++++++++++++++ .../team/activity/ActivityItem.test.ts | 12 +++++- 7 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 50093019..34fe0880 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -29,13 +29,22 @@ export class TeamMemberResolver { ): ResolvedTeamMember[] { const names = new Set(); const explicitNames = new Set(); + const seenNames = new Set(); + const addName = (name: string): void => { + const normalized = name.toLowerCase(); + if (seenNames.has(normalized)) { + return; + } + seenNames.add(normalized); + names.add(name); + }; if (Array.isArray(config.members)) { for (const member of config.members) { if (typeof member?.name === 'string' && member.name.trim() !== '') { const trimmed = member.name.trim(); - names.add(trimmed); - explicitNames.add(trimmed); + addName(trimmed); + explicitNames.add(trimmed.toLowerCase()); } } } @@ -44,8 +53,8 @@ export class TeamMemberResolver { for (const member of metaMembers) { if (typeof member?.name === 'string' && member.name.trim() !== '') { const trimmed = member.name.trim(); - names.add(trimmed); - explicitNames.add(trimmed); + addName(trimmed); + explicitNames.add(trimmed.toLowerCase()); } } } @@ -53,10 +62,13 @@ export class TeamMemberResolver { for (const inboxName of inboxNames) { if (typeof inboxName === 'string' && inboxName.trim() !== '') { const trimmed = inboxName.trim(); - if (!explicitNames.has(trimmed) && looksLikeQualifiedExternalRecipient(trimmed)) { + if ( + !explicitNames.has(trimmed.toLowerCase()) && + looksLikeQualifiedExternalRecipient(trimmed) + ) { continue; } - names.add(trimmed); + addName(trimmed); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index be88c5fe..740b1c34 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3117,7 +3117,10 @@ export class TeamProvisioningService { crossTeamMeta?.conversationId ?? replyMeta?.conversationId, }) - .then(() => { + .then((result) => { + if (result.deduplicated) { + return; + } const msg: InboxMessage = { from: 'user', to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`, @@ -3128,7 +3131,7 @@ export class TeamProvisioningService { (summary || strippedCrossTeamContent).length > 60 ? (summary || strippedCrossTeamContent).slice(0, 57) + '...' : summary || strippedCrossTeamContent, - messageId, + messageId: result.messageId, source: 'cross_team_sent', conversationId: explicitReplyMeta?.conversationId ?? diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 600a3097..e2c6a06b 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -58,9 +58,21 @@ function parseQualifiedRecipient( }; } +export function isQualifiedExternalRecipient( + value: string | undefined, + teamName: string, + localMemberNames?: Set +): boolean { + const recipient = parseQualifiedRecipient(value); + if (!recipient) return false; + if (recipient.teamName === teamName) return false; + return !localMemberNames?.has(value?.trim() ?? ''); +} + interface ActivityItemProps { message: InboxMessage; teamName: string; + localMemberNames?: Set; memberRole?: string; memberColor?: string; recipientColor?: string; @@ -239,6 +251,7 @@ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React. export const ActivityItem = ({ message, teamName, + localMemberNames, memberRole, memberColor, recipientColor, @@ -284,7 +297,7 @@ export const ActivityItem = ({ const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || parsedCrossTeamReplyPrefix !== null || - (qualifiedRecipient?.teamName !== undefined && qualifiedRecipient.teamName !== teamName); + isQualifiedExternalRecipient(message.to, teamName, localMemberNames); const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 49e3abb1..068fe139 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -65,6 +65,7 @@ const MessageRowWithObserver = ({ isNew, zebraShade, memberColorMap, + localMemberNames, onMemberNameClick, onCreateTask, onReply, @@ -82,6 +83,7 @@ const MessageRowWithObserver = ({ isNew?: boolean; zebraShade?: boolean; memberColorMap?: Map; + localMemberNames?: Set; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -131,6 +133,7 @@ const MessageRowWithObserver = ({ isUnread={isUnread} zebraShade={zebraShade} memberColorMap={memberColorMap} + localMemberNames={localMemberNames} onMemberNameClick={onMemberNameClick} onCreateTask={onCreateTask} onReply={onReply} @@ -162,6 +165,7 @@ export const ActivityTimeline = ({ const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const colorMap = members ? buildMemberColorMap(members) : new Map(); + const localMemberNames = new Set((members ?? []).map((member) => member.name.trim())); const memberInfo = new Map(); if (members) { for (const m of members) { @@ -439,6 +443,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(messageKey)} zebraShade={zebraShadeSet.has(realIndex)} memberColorMap={colorMap} + localMemberNames={localMemberNames} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onCreateTask={onCreateTaskFromMessage} onReply={onReplyToMessage} diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index edf91526..ba4d217e 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -107,6 +107,23 @@ describe('TeamMemberResolver', () => { expect(names).toContain('ops.bot'); }); + it('keeps dotted names when config casing differs from inbox casing', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'Ops.Bot', agentType: 'general-purpose' }, + ], + }; + + const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); + const names = members.map((m) => m.name); + + expect(names).toContain('Ops.Bot'); + expect(names).not.toContain('ops.bot'); + }); + it('sets currentTaskId for in_progress task', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index d9bc2bc5..2e2f4008 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -472,6 +472,49 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + it('does not push a duplicate live row when cross-team fallback deduplicates', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + const crossTeamSender = vi.fn(async () => ({ + deliveredToInbox: true, + messageId: 'existing-cross-1', + deduplicated: true, + })); + service.setTeamChangeEmitter(emitter); + service.setCrossTeamSender(crossTeamSender); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'SendMessage', + input: { + type: 'message', + recipient: 'team-best.user', + content: 'Повтор без нового live row', + summary: 'Повтор', + }, + }, + ], + }); + + await vi.waitFor(() => { + expect(crossTeamSender).toHaveBeenCalledTimes(1); + }); + + expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0); + expect(emitter).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'lead-message', + teamName: 'my-team', + detail: 'cross-team-send', + }) + ); + }); + it('does not upgrade dotted local teammate names into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 90d51ec9..7425d4d1 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { getSystemMessageLabel } from '@renderer/components/team/activity/ActivityItem'; +import { + getSystemMessageLabel, + isQualifiedExternalRecipient, +} from '@renderer/components/team/activity/ActivityItem'; describe('ActivityItem legacy system message fallback', () => { it('recognizes historical assignment and review message wording', () => { @@ -18,4 +21,11 @@ describe('ActivityItem legacy system message fallback', () => { expect(getSystemMessageLabel('Approved abcd1234')).toBeNull(); expect(getSystemMessageLabel('Fix request for abcd1234')).toBeNull(); }); + + it('does not classify dotted local teammates as external recipients', () => { + expect(isQualifiedExternalRecipient('ops.bot', 'my-team', new Set(['ops.bot']))).toBe(false); + expect(isQualifiedExternalRecipient('team-best.user', 'my-team', new Set(['ops.bot']))).toBe( + true + ); + }); });