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 type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
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 summary: TeamSummary = {
teamName,

View file

@ -6,6 +6,8 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
@ -78,6 +80,14 @@ export class TeamMemberResolver {
// (recipient of SendMessage to "user"). It's not a real AI teammate.
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[] = [];
for (const name of names) {
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 { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
interface TeamMembersMetaFile {
version: 1;
members: TeamMember[];
@ -90,6 +92,15 @@ export class TeamMembersMetaStore {
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));
}
@ -103,6 +114,15 @@ export class TeamMembersMetaStore {
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 = {
version: 1,
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),

View file

@ -125,11 +125,7 @@ export const SortableTab = ({
role="tab"
tabIndex={0}
aria-selected={isActive}
className={
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'
}
className="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}
onClick={(e) => onTabClick(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" />
{tab.fromSearch && (
<span title="Opened from search">
<Search className="size-3 shrink-0 text-amber-400" />
</span>
)}
{isPinned && (
<span title="Pinned session">
<Pin className="size-3 shrink-0 text-blue-400" />
</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>
<Icon className="size-4 shrink-0" />
{tab.fromSearch && (
<span title="Opened from search">
<Search className="size-3 shrink-0 text-amber-400" />
</span>
)}
{isPinned && (
<span title="Pinned session">
<Pin className="size-3 shrink-0 text-blue-400" />
</span>
)}
<span className="truncate text-sm">{tab.label}</span>
{isTeamTab && (
<TeamTabSectionNav
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>
);
};

View file

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

View file

@ -71,11 +71,11 @@ export const TeamTabSectionNav = ({
}, [open]);
return (
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}>
<div className="shrink-0" onPointerDown={(e) => e.stopPropagation()}>
<button
ref={buttonRef}
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) => {
e.stopPropagation();
setOpen((prev) => !prev);

View file

@ -284,6 +284,7 @@ export const ActivityItem = ({
const isHeaderClickable = Boolean(systemLabel);
const isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system';
return (
<article
@ -293,17 +294,23 @@ export const ActivityItem = ({
backgroundColor:
rateLimited || isApiError
? 'var(--tool-result-error-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
: isSystemMessage
? 'var(--system-activity-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border:
rateLimited || isApiError
? '1px solid var(--tool-result-error-border)'
: CARD_BORDER_STYLE,
: isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
borderLeft:
rateLimited || isApiError
? '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) */}
@ -345,7 +352,7 @@ export const ActivityItem = ({
<MemberBadge
name={message.from}
color={memberColor ?? message.color}
hideAvatar={message.from === 'user'}
hideAvatar={message.from === 'user' || message.from === 'system'}
onClick={onMemberNameClick}
/>

View file

@ -182,6 +182,11 @@
--card-text-lighter: #e2e8f0;
--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) */
--assess-good: #4ade80;
--assess-warning: #fbbf24;
@ -404,6 +409,11 @@
--card-text-lighter: #2a2925;
--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 */
--context-btn-bg: rgba(0, 0, 0, 0.06);
--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());
};
}