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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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