feat: enhance CreateTeamDialog and member management features

- Updated CreateTeamDialog to include automatic team name suggestions and improved default team description handling.
- Enhanced member management with new utility functions for building member draft suggestions and color maps.
- Integrated task and team mention suggestions into MembersEditorSection and MemberDraftRow for better user experience.
- Refactored UI components for improved styling and functionality, ensuring consistent behavior across team management features.
This commit is contained in:
iliya 2026-03-13 14:39:07 +02:00
parent 1d8191e7cc
commit 0df13e206b
9 changed files with 265 additions and 91 deletions

View file

@ -830,8 +830,8 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it. return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate provisioning to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2. CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
CRITICAL: During this initial team provisioning turn, do NOT call mcp__agent-teams__team_launch or mcp__agent-teams__team_stop. This turn is only for creating/provisioning the team state and spawning teammates. CRITICAL: For step 1, use the BUILT-IN TeamCreate tool NOT any mcp__agent-teams__* MCP tool. Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during provisioning. MCP board tools (task_create, task_set_status, etc.) are allowed only in step 3.
You are ${leadName}, the team lead. You are ${leadName}, the team lead.
Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}. Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
@ -841,7 +841,7 @@ ${persistentContext}
Steps (execute in this exact order do NOT skip any step): Steps (execute in this exact order do NOT skip any step):
1) MANDATORY FIRST STEP: Call the TeamCreate tool with team_name=${request.teamName}. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists based on this prompt header. 1) MANDATORY FIRST STEP: Call the BUILT-IN TeamCreate tool (not any MCP tool) with team_name=${request.teamName}. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists.
${step2Block} ${step2Block}
@ -960,8 +960,8 @@ ${memberSpawnInstructions}
return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2. CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
CRITICAL: During this initial team launch/reconnect turn, do NOT call mcp__agent-teams__team_launch or mcp__agent-teams__team_stop. This turn is only for reconnecting the existing team state and spawning teammates. CRITICAL: Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during this turn. MCP board tools (task_create, task_set_status, etc.) are allowed.
You are "${leadName}", the team lead. You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}" and resume pending work. Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
@ -4699,7 +4699,7 @@ export class TeamProvisioningService {
``, ``,
`You are "${leadName}", the team lead of team "${run.teamName}".`, `You are "${leadName}", the team lead of team "${run.teamName}".`,
`You are running in a non-interactive CLI session. Do not ask questions.`, `You are running in a non-interactive CLI session. Do not ask questions.`,
`CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates.`, `CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates.`,
``, ``,
persistentContext, persistentContext,
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',

View file

@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { import {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts, buildMembersFromDrafts,
createMemberDraft, createMemberDraft,
MembersEditorSection, MembersEditorSection,
@ -25,10 +27,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
@ -37,6 +40,7 @@ import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import { getNextSuggestedTeamName } from './teamNameSets';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -52,7 +56,6 @@ const TEAM_COLOR_NAMES = [
'pink', 'pink',
] as const; ] as const;
import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
EffortLevel, EffortLevel,
Project, Project,
@ -97,10 +100,9 @@ interface ValidationResult {
}; };
} }
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
const DEV_DEFAULT_TEAM = { const DEV_DEFAULT_TEAM = {
teamName: 'team-alpha', teamName: 'signal-ops',
description: 'Dev test team for provisioning flow',
} as const; } as const;
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [ const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
@ -134,6 +136,13 @@ function validateTeamNameInline(name: string): string | null {
return null; return null;
} }
function buildDefaultTeamDescription(teamName: string): string {
const trimmedName = teamName.trim();
return trimmedName.length > 0
? `${trimmedName} team for provisioning flow`
: 'Team for provisioning flow';
}
function validateRequest( function validateRequest(
request: TeamCreateRequest, request: TeamCreateRequest,
options?: { requireCwd?: boolean } options?: { requireCwd?: boolean }
@ -224,6 +233,7 @@ export const CreateTeamDialog = ({
const [prepareMessage, setPrepareMessage] = useState<string | null>(null); const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]); const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const prepareRequestSeqRef = useRef(0); const prepareRequestSeqRef = useRef(0);
const lastAutoDescriptionRef = useRef<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ const [fieldErrors, setFieldErrors] = useState<{
teamName?: string; teamName?: string;
members?: string; members?: string;
@ -313,6 +323,7 @@ export const CreateTeamDialog = ({
const resetFormState = (): void => { const resetFormState = (): void => {
setTeamName(''); setTeamName('');
lastAutoDescriptionRef.current = null;
descriptionDraft.clearDraft(); descriptionDraft.clearDraft();
promptDraft.clearDraft(); promptDraft.clearDraft();
promptChipDraft.clearChipDraft(); promptChipDraft.clearChipDraft();
@ -328,6 +339,7 @@ export const CreateTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const dialogTeamNameKey = sanitizeTeamName(teamName.trim()); const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
const suggestedTeamName = getNextSuggestedTeamName(existingTeamNames);
// Clear stale provisioning error when dialog opens // Clear stale provisioning error when dialog opens
useEffect(() => { useEffect(() => {
@ -469,16 +481,34 @@ export const CreateTeamDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open // eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
}, [open]); }, [open]);
useEffect(() => {
if (!open || initialData) {
return;
}
setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev));
}, [initialData, open, suggestedTeamName]);
useEffect(() => { useEffect(() => {
if (!open || !isDev || initialData) { if (!open || !isDev || initialData) {
return; return;
} }
setTeamName((prev) => (prev.trim().length === 0 ? DEV_DEFAULT_TEAM.teamName : prev)); const resolvedTeamName = teamName.trim() || suggestedTeamName;
if (descriptionDraft.value.trim().length === 0) { const nextAutoDescription = buildDefaultTeamDescription(resolvedTeamName);
descriptionDraft.setValue(DEV_DEFAULT_TEAM.description); const currentDescription = descriptionDraft.value.trim();
const previousAutoDescription = lastAutoDescriptionRef.current?.trim() ?? '';
const shouldSyncDescription =
currentDescription.length === 0 || currentDescription === previousAutoDescription;
if (shouldSyncDescription && descriptionDraft.value !== nextAutoDescription) {
lastAutoDescriptionRef.current = nextAutoDescription;
descriptionDraft.setValue(nextAutoDescription);
return;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
}, [open]); if (currentDescription === nextAutoDescription) {
lastAutoDescriptionRef.current = nextAutoDescription;
}
}, [descriptionDraft, initialData, isDev, open, suggestedTeamName, teamName]);
// Pre-select defaultProjectPath when projects loaded (only while dialog is open) // Pre-select defaultProjectPath when projects loaded (only while dialog is open)
useEffect(() => { useEffect(() => {
@ -501,27 +531,19 @@ export const CreateTeamDialog = ({
useFileListCacheWarmer(effectiveCwd || null); useFileListCacheWarmer(effectiveCwd || null);
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
const description = descriptionDraft.value; const description = descriptionDraft.value;
const prompt = promptDraft.value; const prompt = promptDraft.value;
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo(
() => () =>
soloTeam soloTeam
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }] ? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
: members : buildMemberDraftSuggestions(members, memberColorMap),
.filter((m) => m.name.trim()) [memberColorMap, members, soloTeam]
.map((m) => ({
id: m.id,
name: m.name.trim(),
subtitle:
m.roleSelection === CUSTOM_ROLE
? m.customRole.trim() || undefined
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColorByName(m.name.trim()),
})),
[members, soloTeam]
); );
const effectiveModel = useMemo( const effectiveModel = useMemo(
@ -801,7 +823,7 @@ export const CreateTeamDialog = ({
)} )}
value={teamName} value={teamName}
onChange={(event) => handleTeamNameChange(event.target.value)} onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha" placeholder={suggestedTeamName}
/> />
{existingTeamNames.includes(sanitizedTeamName) ? ( {existingTeamNames.includes(sanitizedTeamName) ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> <p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
@ -833,6 +855,8 @@ export const CreateTeamDialog = ({
showJsonEditor showJsonEditor
draftKeyPrefix="createTeam" draftKeyPrefix="createTeam"
projectPath={effectiveCwd || null} projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
hideContent={soloTeam} hideContent={soloTeam}
headerExtra={ headerExtra={
<div className="space-y-2"> <div className="space-y-2">
@ -930,6 +954,8 @@ export const CreateTeamDialog = ({
value={prompt} value={prompt}
onValueChange={promptDraft.setValue} onValueChange={promptDraft.setValue}
suggestions={soloTeam ? [] : mentionSuggestions} suggestions={soloTeam ? [] : mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={effectiveCwd || null} projectPath={effectiveCwd || null}
chips={promptChipDraft.chips} chips={promptChipDraft.chips}
onChipRemove={promptChipDraft.removeChip} onChipRemove={promptChipDraft.removeChip}

View file

@ -0,0 +1,67 @@
const TEAM_NAME_SETS = [
['signal-ops', 'forge-labs', 'atlas-hq', 'relay-works', 'beacon-desk', 'vector-room'],
['northstar-core', 'summit-ops', 'harbor-labs', 'pilot-desk', 'mission-control', 'launchpad'],
['quartz-forge', 'ember-collective', 'prism-works', 'cinder-labs', 'aurora-room', 'sable-ops'],
['delta-studio', 'comet-hub', 'orbit-core', 'kernel-crew', 'circuit-labs', 'flux-team'],
] as const;
function normalizeTeamName(name: string): string {
return name.trim().toLowerCase();
}
function belongsToBaseTeamName(name: string, baseName: string): boolean {
const normalized = normalizeTeamName(name);
return normalized === baseName || normalized.startsWith(`${baseName}-`);
}
function getPreferredTeamNameSet(existingNames: readonly string[]): readonly string[] {
for (const nameSet of TEAM_NAME_SETS) {
if (
nameSet.some((candidate) =>
existingNames.some((name) => belongsToBaseTeamName(name, candidate))
)
) {
return nameSet;
}
}
return TEAM_NAME_SETS[0];
}
function createUniqueTeamName(baseName: string, existingNames: readonly string[]): string {
const normalizedExisting = new Set(existingNames.map(normalizeTeamName).filter(Boolean));
if (!normalizedExisting.has(baseName)) {
return baseName;
}
let suffix = 2;
while (normalizedExisting.has(`${baseName}-${suffix}`)) {
suffix += 1;
}
return `${baseName}-${suffix}`;
}
export function getNextSuggestedTeamName(existingNames: readonly string[]): string {
const normalizedExisting = new Set(existingNames.map(normalizeTeamName).filter(Boolean));
const preferredSet = getPreferredTeamNameSet(existingNames);
for (const candidate of preferredSet) {
if (!normalizedExisting.has(candidate)) {
return candidate;
}
}
for (const nameSet of TEAM_NAME_SETS) {
for (const candidate of nameSet) {
if (!normalizedExisting.has(candidate)) {
return candidate;
}
}
}
const fallbackBaseName = preferredSet[existingNames.length % preferredSet.length] ?? 'signal-ops';
return createUniqueTeamName(fallbackBaseName, existingNames);
}
export { TEAM_NAME_SETS };

View file

@ -20,6 +20,7 @@ import type { MentionSuggestion } from '@renderer/types/mention';
interface MemberDraftRowProps { interface MemberDraftRowProps {
member: MemberDraft; member: MemberDraft;
index: number; index: number;
resolvedColor?: string;
nameError: string | null; nameError: string | null;
onNameChange: (id: string, name: string) => void; onNameChange: (id: string, name: string) => void;
onRoleChange: (id: string, roleSelection: string) => void; onRoleChange: (id: string, roleSelection: string) => void;
@ -31,11 +32,14 @@ interface MemberDraftRowProps {
draftKeyPrefix?: string; draftKeyPrefix?: string;
projectPath?: string | null; projectPath?: string | null;
mentionSuggestions?: MentionSuggestion[]; mentionSuggestions?: MentionSuggestion[];
taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[];
} }
export const MemberDraftRow = ({ export const MemberDraftRow = ({
member, member,
index, index,
resolvedColor,
nameError, nameError,
onNameChange, onNameChange,
onRoleChange, onRoleChange,
@ -47,10 +51,12 @@ export const MemberDraftRow = ({
draftKeyPrefix, draftKeyPrefix,
projectPath, projectPath,
mentionSuggestions = [], mentionSuggestions = [],
taskSuggestions,
teamSuggestions,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const memberColorSet = getTeamColorSet( const memberColorSet = getTeamColorSet(
getMemberColorByName(member.name.trim() || `member-${index}`) resolvedColor ?? getMemberColorByName(member.name.trim() || `member-${index}`)
); );
const [workflowExpanded, setWorkflowExpanded] = useState(false); const [workflowExpanded, setWorkflowExpanded] = useState(false);
const [modelExpanded, setModelExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false);
@ -119,7 +125,7 @@ export const MemberDraftRow = ({
return ( return (
<div <div
className="relative grid grid-cols-1 gap-2 overflow-hidden rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]" className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
style={{ style={{
backgroundColor: isLight backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)' ? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -128,7 +134,7 @@ export const MemberDraftRow = ({
}} }}
> >
<div <div
className="absolute inset-y-0 left-0 w-1" className="absolute inset-y-0 left-0 w-1 rounded-l-md"
style={{ backgroundColor: memberColorSet.border }} style={{ backgroundColor: memberColorSet.border }}
aria-hidden="true" aria-hidden="true"
/> />
@ -215,6 +221,8 @@ export const MemberDraftRow = ({
value={workflowDraft.value} value={workflowDraft.value}
onValueChange={handleWorkflowChange} onValueChange={handleWorkflowChange}
suggestions={suggestionsExcludingSelf} suggestions={suggestionsExcludingSelf}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
chips={chips} chips={chips}
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath ?? undefined} projectPath={projectPath ?? undefined}

View file

@ -3,14 +3,19 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
import { MemberDraftRow } from './MemberDraftRow'; import { MemberDraftRow } from './MemberDraftRow';
import { getNextSuggestedMemberName } from './memberNameSets'; import { getNextSuggestedMemberName } from './memberNameSets';
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils'; import {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
createMemberDraft,
getMemberDraftRole,
getWorkflowForExport,
} from './membersEditorUtils';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
@ -20,12 +25,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
const arr = drafts const arr = drafts
.filter((d) => d.name.trim()) .filter((d) => d.name.trim())
.map((d) => { .map((d) => {
const role = const role = getMemberDraftRole(d);
d.roleSelection === CUSTOM_ROLE
? d.customRole.trim() || undefined
: d.roleSelection === NO_ROLE
? undefined
: d.roleSelection.trim() || undefined;
const obj: Record<string, string> = { name: d.name.trim() }; const obj: Record<string, string> = { name: d.name.trim() };
if (role) obj.role = role; if (role) obj.role = role;
const workflow = getWorkflowForExport(d); const workflow = getWorkflowForExport(d);
@ -64,6 +64,10 @@ export interface MembersEditorSectionProps {
draftKeyPrefix?: string; draftKeyPrefix?: string;
/** Project path for @file mentions in workflow */ /** Project path for @file mentions in workflow */
projectPath?: string | null; projectPath?: string | null;
/** Task suggestions for #task references in workflow */
taskSuggestions?: MentionSuggestion[];
/** Team suggestions for @@team mentions in workflow */
teamSuggestions?: MentionSuggestion[];
/** Extra content rendered right below the "Members" label row */ /** Extra content rendered right below the "Members" label row */
headerExtra?: React.ReactNode; headerExtra?: React.ReactNode;
/** When true, hides member rows and action buttons (label + headerExtra still visible) */ /** When true, hides member rows and action buttons (label + headerExtra still visible) */
@ -79,6 +83,8 @@ export const MembersEditorSection = ({
showJsonEditor = true, showJsonEditor = true,
draftKeyPrefix, draftKeyPrefix,
projectPath, projectPath,
taskSuggestions,
teamSuggestions,
headerExtra, headerExtra,
hideContent = false, hideContent = false,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
@ -152,23 +158,11 @@ export const MembersEditorSection = ({
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length; const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo(
() => () => buildMemberDraftSuggestions(members, memberColorMap),
members [members, memberColorMap]
.filter((m) => m.name.trim())
.map((m) => ({
id: m.id,
name: m.name.trim(),
subtitle:
m.roleSelection === CUSTOM_ROLE
? m.customRole.trim() || undefined
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColorByName(m.name.trim()),
})),
[members]
); );
return ( return (
@ -198,6 +192,7 @@ export const MembersEditorSection = ({
key={member.id} key={member.id}
member={member} member={member}
index={index} index={index}
resolvedColor={memberColorMap.get(member.name.trim())}
nameError={validateMemberName?.(member.name) ?? null} nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName} onNameChange={updateMemberName}
onRoleChange={updateMemberRole} onRoleChange={updateMemberRole}
@ -209,6 +204,8 @@ export const MembersEditorSection = ({
draftKeyPrefix={draftKeyPrefix} draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath} projectPath={projectPath}
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
/> />
))} ))}
{jsonEditorOpen && showJsonEditor ? ( {jsonEditorOpen && showJsonEditor ? (
@ -237,7 +234,10 @@ export const MembersEditorSection = ({
export type { MemberDraft } from './membersEditorTypes'; export type { MemberDraft } from './membersEditorTypes';
export { export {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts, buildMembersFromDrafts,
createMemberDraft, createMemberDraft,
getMemberDraftRole,
validateMemberNameInline, validateMemberNameInline,
} from './membersEditorUtils'; } from './membersEditorUtils';

View file

@ -1,7 +1,9 @@
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { TeamProvisioningMemberInput } from '@shared/types'; import type { TeamProvisioningMemberInput } from '@shared/types';
function isValidMemberName(name: string): boolean { function isValidMemberName(name: string): boolean {
@ -34,6 +36,41 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
}; };
} }
export function buildMemberDraftColorMap(
members: ReadonlyArray<Pick<MemberDraft, 'name'>>
): Map<string, string> {
return buildMemberColorMap(
members
.map((member) => member.name.trim())
.filter(Boolean)
.map((name) => ({ name }))
);
}
/** Resolves a MemberDraft's role selection to a display string. */
export function getMemberDraftRole(member: MemberDraft): string | undefined {
return member.roleSelection === CUSTOM_ROLE
? member.customRole.trim() || undefined
: member.roleSelection === NO_ROLE
? undefined
: member.roleSelection.trim() || undefined;
}
/** Builds MentionSuggestion[] from MemberDraft[], reusing color map and role resolution. */
export function buildMemberDraftSuggestions(
members: MemberDraft[],
colorMap: Map<string, string>
): MentionSuggestion[] {
return members
.filter((m) => m.name.trim())
.map((m) => ({
id: m.id,
name: m.name.trim(),
subtitle: getMemberDraftRole(m),
color: colorMap.get(m.name.trim()) ?? undefined,
}));
}
/** Resolves workflow for export (JSON or API): serializes chips when present. */ /** Resolves workflow for export (JSON or API): serializes chips when present. */
export function getWorkflowForExport(member: MemberDraft): string | undefined { export function getWorkflowForExport(member: MemberDraft): string | undefined {
const workflowRaw = member.workflow?.trim(); const workflowRaw = member.workflow?.trim();
@ -50,13 +87,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
return null; return null;
} }
const role = const role = getMemberDraftRole(member);
member.roleSelection === CUSTOM_ROLE
? member.customRole.trim() || undefined
: member.roleSelection === NO_ROLE
? undefined
: member.roleSelection.trim() || undefined;
const result: TeamProvisioningMemberInput = { name, role }; const result: TeamProvisioningMemberInput = { name, role };
const workflow = getWorkflowForExport(member); const workflow = getWorkflowForExport(member);
if (workflow) result.workflow = workflow; if (workflow) result.workflow = workflow;

View file

@ -164,7 +164,9 @@ export const MentionSuggestionList = ({
<UsersRound <UsersRound
size={13} size={13}
className="shrink-0" className="shrink-0"
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }} style={{
color: colorSet ? getThemedText(colorSet, isLight) : 'var(--color-text-muted)',
}}
/> />
) : ( ) : (
<span <span
@ -180,7 +182,7 @@ export const MentionSuggestionList = ({
isTask isTask
? { color: 'var(--color-link, #60a5fa)' } ? { color: 'var(--color-link, #60a5fa)' }
: colorSet : colorSet
? { color: colorSet.text } ? { color: getThemedText(colorSet, isLight) }
: undefined : undefined
} }
> >

View file

@ -56,6 +56,8 @@ interface TaskSegment {
value: string; value: string;
suggestion: MentionSuggestion; suggestion: MentionSuggestion;
encoded: boolean; encoded: boolean;
/** Zero-width metadata chars rendered in backdrop for caret alignment */
hiddenSuffix?: string;
} }
interface UrlSegment { interface UrlSegment {
@ -197,11 +199,16 @@ function parseSuggestionSegments(
if (match.start > lastEnd) { if (match.start > lastEnd) {
segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions)); segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions));
} }
// Compute hidden suffix: zero-width metadata chars between visible text and match.end
const visibleEnd = match.start + match.raw.length;
const hiddenSuffix =
match.encoded && match.end > visibleEnd ? text.slice(visibleEnd, match.end) : undefined;
segments.push({ segments.push({
type: 'task', type: 'task',
value: match.raw, value: match.raw,
suggestion: match.suggestion, suggestion: match.suggestion,
encoded: match.encoded, encoded: match.encoded,
hiddenSuffix,
}); });
lastEnd = match.end; lastEnd = match.end;
} }
@ -987,25 +994,26 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
} }
if (seg.type === 'task') { if (seg.type === 'task') {
return ( return (
<span <React.Fragment key={idx}>
key={idx} <span
className={ className={seg.encoded ? 'rounded' : 'underline decoration-transparent'}
seg.encoded style={
? 'rounded px-1.5 py-0.5 font-medium' seg.encoded
: 'font-medium underline decoration-transparent' ? {
} backgroundColor: 'rgba(59, 130, 246, 0.15)',
style={ color: PROSE_LINK,
seg.encoded // Only vertical padding (doesn't affect inline text flow).
? { // No horizontal padding/margin/box-shadow spread to avoid
backgroundColor: 'rgba(59, 130, 246, 0.15)', // caret drift or visual overlap with adjacent text.
color: PROSE_LINK, padding: '2px 0',
boxShadow: '0 0 0 1.5px rgba(59, 130, 246, 0.15)', }
} : { color: PROSE_LINK }
: { color: PROSE_LINK } }
} >
> {seg.value}
{seg.value} </span>
</span> {seg.hiddenSuffix}
</React.Fragment>
); );
} }
if (seg.type === 'url') { if (seg.type === 'url') {
@ -1121,16 +1129,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
</div> </div>
{showFooter ? ( {showFooter ? (
<div className="mt-1 flex items-center justify-between"> <div className="mt-1 flex items-start justify-between gap-2">
{showHintRow ? ( {showHintRow ? (
<span <span
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300" className="block min-h-6 flex-1 overflow-hidden text-[10px] leading-3 text-[var(--color-text-muted)] transition-opacity duration-300"
style={{ opacity: tipVisible ? 1 : 0 }} style={{ opacity: tipVisible ? 1 : 0, maxHeight: '1.5rem' }}
> >
{resolvedHintText} {resolvedHintText}
</span> </span>
) : ( ) : (
<span /> <span className="min-h-6 flex-1" />
)} )}
{footerRight} {footerRight}
</div> </div>

View file

@ -5,6 +5,8 @@
* Used by TeammateMessageItem and SubagentItem when displaying team members. * Used by TeammateMessageItem and SubagentItem when displaying team members.
*/ */
import { MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
export interface TeamColorSet { export interface TeamColorSet {
/** Border accent color */ /** Border accent color */
border: string; border: string;
@ -128,6 +130,33 @@ export function getSubagentTypeColorSet(
/** Assignable visual colors (excludes reserved 'user'). */ /** Assignable visual colors (excludes reserved 'user'). */
const ASSIGNABLE_COLORS = COLOR_NAMES.filter((c) => c !== 'user'); const ASSIGNABLE_COLORS = COLOR_NAMES.filter((c) => c !== 'user');
function hsla(hue: number, saturation: number, lightness: number, alpha = 1): string {
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
}
function buildGeneratedMemberColorSet(colorName: string): TeamColorSet | null {
const paletteIndex = MEMBER_COLOR_PALETTE.indexOf(
colorName as (typeof MEMBER_COLOR_PALETTE)[number]
);
if (paletteIndex === -1) {
return null;
}
// Spread the extended member palette across the hue wheel so distinct palette
// names stay visually distinct instead of collapsing back into 8 base colors.
const hue = Math.round((paletteIndex / MEMBER_COLOR_PALETTE.length) * 360);
const saturation = 72;
return {
border: hsla(hue, saturation, 50),
borderLight: hsla(hue, saturation, 44),
badge: hsla(hue, saturation, 50, 0.15),
badgeLight: hsla(hue, saturation, 50, 0.12),
text: hsla(hue, 78, 66),
textLight: hsla(hue, 82, 36),
};
}
export function getTeamColorSet(colorName: string): TeamColorSet { export function getTeamColorSet(colorName: string): TeamColorSet {
if (!colorName) return DEFAULT_COLOR; if (!colorName) return DEFAULT_COLOR;
@ -135,6 +164,9 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
const named = TEAMMATE_COLORS[colorName.toLowerCase()]; const named = TEAMMATE_COLORS[colorName.toLowerCase()];
if (named) return named; if (named) return named;
const generatedMemberColor = buildGeneratedMemberColorSet(colorName.toLowerCase());
if (generatedMemberColor) return generatedMemberColor;
// If it's a hex color, generate a set from it // If it's a hex color, generate a set from it
if (colorName.startsWith('#')) { if (colorName.startsWith('#')) {
return { return {