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:
parent
80147c9900
commit
c006fad97d
9 changed files with 132 additions and 40 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
41
src/shared/utils/teamMemberName.ts
Normal file
41
src/shared/utils/teamMemberName.ts
Normal 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());
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue