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
This commit is contained in:
iliya 2026-03-10 01:10:21 +02:00
parent 477c28ed30
commit 0e7e34ab8a
5 changed files with 135 additions and 33 deletions

View file

@ -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<string>();
const explicitNames = new Set<string>();
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);
}
}

View file

@ -1223,9 +1223,11 @@ export class TeamProvisioningService {
private parseCrossTeamRecipient(
currentTeam: string,
recipient: string
recipient: string,
localRecipientNames: Set<string>
): { 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(

View file

@ -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 ? (
<>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
{selectedTarget?.leadName ? (
<MemberBadge
name={selectedTarget.leadName}
color={selectedTarget.leadColor}
size="sm"
/>
) : selectedTargetColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: selectedTargetColor }}
/>
) : (
<ArrowRightLeft size={11} className="shrink-0" />
)}
) : null}
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
@ -448,16 +448,17 @@ export const MessageComposer = ({
setTeamSelectorOpen(false);
}}
>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
/>
{target.leadName ? (
<MemberBadge name={target.leadName} color={target.leadColor} size="sm" />
) : target.color ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: target.color }}
/>
) : (
<ArrowRightLeft size={11} className="shrink-0 text-purple-400" />
)}
) : null}
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]">
{target.displayName}

View file

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

View file

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