From 0e7e34ab8a2c8490b79632162b4a6c8fef971ca3 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 01:10:21 +0200 Subject: [PATCH] fix: show team color dots in message composer team selector - Use getTeamColorSet/nameColorSet for accurate team colors matching SortableTab and TeamDetailView approach - Always show team color dot, including next to cross-team lead badges - Remove hash-based resolveTeamColor fallback in favor of real config colors - Clean up unused imports (ArrowRightLeft, selectedTargetColor) - Add TeamMemberResolver and TeamProvisioningService enhancements with tests --- src/main/services/team/TeamMemberResolver.ts | 26 ++++++++- .../services/team/TeamProvisioningService.ts | 17 +++++- .../team/messages/MessageComposer.tsx | 57 ++++++++++--------- .../services/team/TeamMemberResolver.test.ts | 36 ++++++++++++ ...eamProvisioningServiceLiveMessages.test.ts | 32 +++++++++++ 5 files changed, 135 insertions(+), 33 deletions(-) diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index b011af30..50093019 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -8,6 +8,17 @@ import type { TeamTaskWithKanban, } from '@shared/types'; +const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; + +function looksLikeQualifiedExternalRecipient(name: string): boolean { + const trimmed = name.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0 || dot === trimmed.length - 1) return false; + const teamName = trimmed.slice(0, dot).trim(); + const memberName = trimmed.slice(dot + 1).trim(); + return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0; +} + export class TeamMemberResolver { resolveMembers( config: TeamConfig, @@ -17,11 +28,14 @@ export class TeamMemberResolver { messages: InboxMessage[] ): ResolvedTeamMember[] { const names = new Set(); + const explicitNames = new Set(); if (Array.isArray(config.members)) { for (const member of config.members) { if (typeof member?.name === 'string' && member.name.trim() !== '') { - names.add(member.name.trim()); + const trimmed = member.name.trim(); + names.add(trimmed); + explicitNames.add(trimmed); } } } @@ -29,14 +43,20 @@ export class TeamMemberResolver { if (Array.isArray(metaMembers)) { for (const member of metaMembers) { if (typeof member?.name === 'string' && member.name.trim() !== '') { - names.add(member.name.trim()); + const trimmed = member.name.trim(); + names.add(trimmed); + explicitNames.add(trimmed); } } } for (const inboxName of inboxNames) { if (typeof inboxName === 'string' && inboxName.trim() !== '') { - names.add(inboxName.trim()); + const trimmed = inboxName.trim(); + if (!explicitNames.has(trimmed) && looksLikeQualifiedExternalRecipient(trimmed)) { + continue; + } + names.add(trimmed); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6c564ff1..be88c5fe 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1223,9 +1223,11 @@ export class TeamProvisioningService { private parseCrossTeamRecipient( currentTeam: string, - recipient: string + recipient: string, + localRecipientNames: Set ): { teamName: string; memberName: string } | null { const trimmed = recipient.trim(); + if (localRecipientNames.has(trimmed)) return null; const dot = trimmed.indexOf('.'); if (dot <= 0 || dot === trimmed.length - 1) return null; const teamName = trimmed.slice(0, dot).trim(); @@ -3072,8 +3074,19 @@ export class TeamProvisioningService { if (cleanContent.trim().length === 0) continue; const strippedCrossTeamContent = stripCrossTeamPrefix(cleanContent).trim(); if (strippedCrossTeamContent.length === 0) continue; + const localRecipientNames = new Set( + (run.request.members ?? []) + .map((member) => (typeof member.name === 'string' ? member.name.trim() : '')) + .filter((name) => name.length > 0) + ); + localRecipientNames.add('user'); + localRecipientNames.add('team-lead'); - const crossTeamRecipient = this.parseCrossTeamRecipient(run.teamName, recipient); + const crossTeamRecipient = this.parseCrossTeamRecipient( + run.teamName, + recipient, + localRecipientNames + ); if (crossTeamRecipient && this.crossTeamSender) { const explicitReplyMeta = parseCrossTeamReplyPrefix(cleanContent); const inferredReplyMeta = this.resolveCrossTeamReplyMetadata( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 92bbd388..2a47f6ce 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -12,17 +12,10 @@ import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { nameColorSet } from '@renderer/utils/projectColor'; import { MAX_TEXT_LENGTH } from '@shared/constants'; -import { - AlertCircle, - ArrowRightLeft, - Check, - ChevronDown, - ImagePlus, - Mic, - Search, - Send, -} from 'lucide-react'; +import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; @@ -85,7 +78,6 @@ export const MessageComposer = ({ const isCrossTeam = selectedTeam !== null; const selectedTarget = crossTeamTargets.find((t) => t.teamName === selectedTeam); const targetDisplayName = selectedTarget?.displayName ?? selectedTeam; - const selectedTargetColor = selectedTarget?.color; const crossTeamHintText = isCrossTeam ? 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.' : undefined; @@ -103,7 +95,12 @@ export const MessageComposer = ({ }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); - const currentTeamColor = useStore((s) => s.selectedTeamData?.config.color ?? undefined); + const currentTeamColor = useStore((s) => { + const configColor = s.selectedTeamData?.config.color; + if (configColor) return getTeamColorSet(configColor).border; + const displayName = s.selectedTeamData?.config.name ?? teamName; + return nameColorSet(displayName).border; + }); const isProvisioning = useStore((s) => Object.values(s.provisioningRuns).some( (run) => @@ -369,20 +366,23 @@ export const MessageComposer = ({ > {isCrossTeam ? ( <> + {selectedTarget?.leadName ? ( - ) : selectedTargetColor ? ( - - ) : ( - - )} + ) : null} {targetDisplayName} ) : ( @@ -448,16 +448,17 @@ export const MessageComposer = ({ setTeamSelectorOpen(false); }} > + {target.leadName ? ( - ) : target.color ? ( - - ) : ( - - )} + ) : null}
{target.displayName} diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index d2c17f5e..edf91526 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -71,6 +71,42 @@ describe('TeamMemberResolver', () => { expect(names).toContain('alice'); }); + it('ignores qualified external inbox names unless explicitly configured', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }], + }; + const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }]; + const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead']; + const tasks: TeamTask[] = []; + const messages: InboxMessage[] = []; + + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const names = members.map((m) => m.name); + + expect(names).toContain('alice'); + expect(names).toContain('team-lead'); + expect(names).not.toContain('team-best.user'); + expect(names).not.toContain('dream-team.team-lead'); + }); + + it('keeps dotted names when they are explicitly configured members', () => { + 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'); + }); + 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 1ee040cf..d9bc2bc5 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -471,4 +471,36 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + + it('does not upgrade dotted local teammate names into cross-team sends', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-1' })); + service.setCrossTeamSender(crossTeamSender); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + run.request.members.push({ name: 'ops.bot', role: 'Specialist' }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'SendMessage', + input: { + type: 'message', + recipient: 'ops.bot', + content: 'Please verify the rollout.', + summary: 'Verify rollout', + }, + }, + ], + }); + + expect(crossTeamSender).not.toHaveBeenCalled(); + expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); + expect(hoisted.sendInboxMessage).toHaveBeenCalledWith( + 'my-team', + expect.objectContaining({ member: 'ops.bot' }) + ); + }); });