feat: implement CLI auto-suffix name handling across team services

- Added functionality to drop CLI auto-suffixed duplicates (e.g., "alice-2") when the base name exists in TeamConfigReader, TeamMemberResolver, and TeamMembersMetaStore.
- Introduced createCliAutoSuffixNameGuard utility to manage name validation.
- Updated ActivityItem component to handle system messages with distinct styling.
- Enhanced CSS variables for system activity messages in index.css.
- Refactored SortableTab and TeamTabSectionNav components for improved layout and accessibility.
This commit is contained in:
iliya 2026-03-05 19:12:41 +02:00
parent 80147c9900
commit c006fad97d
9 changed files with 132 additions and 40 deletions

View file

@ -8,6 +8,7 @@ import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
const logger = createLogger('Service:TeamConfigReader'); const logger = createLogger('Service:TeamConfigReader');
@ -244,6 +245,15 @@ export class TeamConfigReader {
} }
} }
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(memberMap.values()).map((m) => m.name);
const keepName = createCliAutoSuffixNameGuard(allNames);
for (const [key, member] of Array.from(memberMap.entries())) {
if (!keepName(member.name)) {
memberMap.delete(key);
}
}
const members = Array.from(memberMap.values()); const members = Array.from(memberMap.values());
const summary: TeamSummary = { const summary: TeamSummary = {
teamName, teamName,

View file

@ -6,6 +6,8 @@ import type {
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
export class TeamMemberResolver { export class TeamMemberResolver {
resolveMembers( resolveMembers(
config: TeamConfig, config: TeamConfig,
@ -78,6 +80,14 @@ export class TeamMemberResolver {
// (recipient of SendMessage to "user"). It's not a real AI teammate. // (recipient of SendMessage to "user"). It's not a real AI teammate.
names.delete('user'); names.delete('user');
// Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists.
const keepName = createCliAutoSuffixNameGuard(names);
for (const name of Array.from(names)) {
if (!keepName(name)) {
names.delete(name);
}
}
const members: ResolvedTeamMember[] = []; const members: ResolvedTeamMember[] = [];
for (const name of names) { for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);

View file

@ -7,6 +7,8 @@ import { atomicWriteAsync } from './atomicWrite';
import type { TeamMember } from '@shared/types'; import type { TeamMember } from '@shared/types';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
interface TeamMembersMetaFile { interface TeamMembersMetaFile {
version: 1; version: 1;
members: TeamMember[]; members: TeamMember[];
@ -90,6 +92,15 @@ export class TeamMembersMetaStore {
deduped.set(normalized.name, normalized); deduped.set(normalized.name, normalized);
} }
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(deduped.keys());
const keepName = createCliAutoSuffixNameGuard(allNames);
for (const name of allNames) {
if (!keepName(name)) {
deduped.delete(name);
}
}
return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)); return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name));
} }
@ -103,6 +114,15 @@ export class TeamMembersMetaStore {
deduped.set(normalized.name, normalized); deduped.set(normalized.name, normalized);
} }
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(deduped.keys());
const keepName = createCliAutoSuffixNameGuard(allNames);
for (const name of allNames) {
if (!keepName(name)) {
deduped.delete(name);
}
}
const payload: TeamMembersMetaFile = { const payload: TeamMembersMetaFile = {
version: 1, version: 1,
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),

View file

@ -125,11 +125,7 @@ export const SortableTab = ({
role="tab" role="tab"
tabIndex={0} tabIndex={0}
aria-selected={isActive} aria-selected={isActive}
className={ className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
isTeamTab
? 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab flex-col rounded-md'
: 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5'
}
style={style} style={style}
onClick={(e) => onTabClick(tab.id, e)} onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)}
@ -143,32 +139,18 @@ export const SortableTab = ({
} }
}} }}
> >
<div className={isTeamTab ? 'flex min-w-0 items-center gap-2 px-3 pb-0.5 pt-1' : 'contents'}> <Icon className="size-4 shrink-0" />
<Icon className="size-4 shrink-0" /> {tab.fromSearch && (
{tab.fromSearch && ( <span title="Opened from search">
<span title="Opened from search"> <Search className="size-3 shrink-0 text-amber-400" />
<Search className="size-3 shrink-0 text-amber-400" /> </span>
</span> )}
)} {isPinned && (
{isPinned && ( <span title="Pinned session">
<span title="Pinned session"> <Pin className="size-3 shrink-0 text-blue-400" />
<Pin className="size-3 shrink-0 text-blue-400" /> </span>
</span> )}
)} <span className="truncate text-sm">{tab.label}</span>
<span className="truncate text-sm">{tab.label}</span>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
{isTeamTab && ( {isTeamTab && (
<TeamTabSectionNav <TeamTabSectionNav
teamName={tab.teamName!} teamName={tab.teamName!}
@ -182,6 +164,18 @@ export const SortableTab = ({
}} }}
/> />
)} )}
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Close tab"
>
<X className="size-3" />
</button>
</div> </div>
); );
}; };

View file

@ -309,7 +309,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
overflowX: 'auto', overflowX: 'auto',
overflowY: 'clip', overflowY: 'hidden',
} as React.CSSProperties } as React.CSSProperties
} }
> >

View file

@ -71,11 +71,11 @@ export const TeamTabSectionNav = ({
}, [open]); }, [open]);
return ( return (
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}> <div className="shrink-0" onPointerDown={(e) => e.stopPropagation()}>
<button <button
ref={buttonRef} ref={buttonRef}
type="button" type="button"
className="flex h-3.5 w-full items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]" className="flex size-4 items-center justify-center rounded-sm text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpen((prev) => !prev); setOpen((prev) => !prev);

View file

@ -284,6 +284,7 @@ export const ActivityItem = ({
const isHeaderClickable = Boolean(systemLabel); const isHeaderClickable = Boolean(systemLabel);
const isUserSent = message.source === 'user_sent'; const isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system';
return ( return (
<article <article
@ -293,17 +294,23 @@ export const ActivityItem = ({
backgroundColor: backgroundColor:
rateLimited || isApiError rateLimited || isApiError
? 'var(--tool-result-error-bg)' ? 'var(--tool-result-error-bg)'
: zebraShade : isSystemMessage
? CARD_BG_ZEBRA ? 'var(--system-activity-bg)'
: CARD_BG, : zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border: border:
rateLimited || isApiError rateLimited || isApiError
? '1px solid var(--tool-result-error-border)' ? '1px solid var(--tool-result-error-border)'
: CARD_BORDER_STYLE, : isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
borderLeft: borderLeft:
rateLimited || isApiError rateLimited || isApiError
? '3px solid var(--tool-result-error-text)' ? '3px solid var(--tool-result-error-text)'
: `3px solid ${colors.border}`, : isSystemMessage
? '3px solid var(--system-activity-accent)'
: `3px solid ${colors.border}`,
}} }}
> >
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */} {/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
@ -345,7 +352,7 @@ export const ActivityItem = ({
<MemberBadge <MemberBadge
name={message.from} name={message.from}
color={memberColor ?? message.color} color={memberColor ?? message.color}
hideAvatar={message.from === 'user'} hideAvatar={message.from === 'user' || message.from === 'system'}
onClick={onMemberNameClick} onClick={onMemberNameClick}
/> />

View file

@ -182,6 +182,11 @@
--card-text-lighter: #e2e8f0; --card-text-lighter: #e2e8f0;
--card-separator: #2a2c38; --card-separator: #2a2c38;
/* System activity messages */
--system-activity-bg: rgba(59, 130, 246, 0.06);
--system-activity-border: rgba(59, 130, 246, 0.12);
--system-activity-accent: rgba(96, 165, 250, 0.5);
/* Assessment severity colors (badges, health indicators) */ /* Assessment severity colors (badges, health indicators) */
--assess-good: #4ade80; --assess-good: #4ade80;
--assess-warning: #fbbf24; --assess-warning: #fbbf24;
@ -404,6 +409,11 @@
--card-text-lighter: #2a2925; --card-text-lighter: #2a2925;
--card-separator: #d5d3cf; --card-separator: #d5d3cf;
/* System activity messages */
--system-activity-bg: rgba(59, 130, 246, 0.06);
--system-activity-border: rgba(59, 130, 246, 0.15);
--system-activity-accent: rgba(37, 99, 235, 0.5);
/* Sticky Context button - transparent glass */ /* Sticky Context button - transparent glass */
--context-btn-bg: rgba(0, 0, 0, 0.06); --context-btn-bg: rgba(0, 0, 0, 0.06);
--context-btn-bg-hover: rgba(0, 0, 0, 0.1); --context-btn-bg-hover: rgba(0, 0, 0, 0.1);

View file

@ -0,0 +1,41 @@
export function parseNumericSuffixName(
name: string
): { base: string; suffix: number } | null {
const trimmed = name.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(.+)-(\d+)$/);
if (!match?.[1] || !match[2]) return null;
const suffix = Number(match[2]);
if (!Number.isFinite(suffix)) return null;
return { base: match[1], suffix };
}
/**
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
* (e.g. "alice" "alice-2"). We treat "-2+" as an auto-suffix only when the base
* name also exists among the current set of names.
*
* Important: do NOT treat "-1" as auto-suffix; it's commonly intentional ("dev-1").
*/
export function createCliAutoSuffixNameGuard(allNames: Iterable<string>): (name: string) => boolean {
const trimmed: string[] = [];
const seen = new Set<string>();
for (const n of allNames) {
if (typeof n !== 'string') continue;
const t = n.trim();
if (!t) continue;
if (seen.has(t)) continue;
seen.add(t);
trimmed.push(t);
}
const allLower = new Set(trimmed.map((n) => n.toLowerCase()));
return (name: string): boolean => {
const info = parseNumericSuffixName(name);
if (!info) return true;
if (info.suffix < 2) return true;
return !allLower.has(info.base.toLowerCase());
};
}