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:
parent
477c28ed30
commit
0e7e34ab8a
5 changed files with 135 additions and 33 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue