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:
parent
1d8191e7cc
commit
0df13e206b
9 changed files with 265 additions and 91 deletions
|
|
@ -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.
|
||||
|
||||
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: 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: 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: 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.
|
||||
|
||||
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):
|
||||
|
||||
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}
|
||||
|
||||
|
|
@ -960,8 +960,8 @@ ${memberSpawnInstructions}
|
|||
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.
|
||||
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: 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: 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: 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.
|
||||
|
||||
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 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,
|
||||
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
MembersEditorSection,
|
||||
|
|
@ -25,10 +27,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
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 { cn } from '@renderer/lib/utils';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
|
|
@ -37,6 +40,7 @@ import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
|||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
|
@ -52,7 +56,6 @@ const TEAM_COLOR_NAMES = [
|
|||
'pink',
|
||||
] as const;
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
EffortLevel,
|
||||
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 = {
|
||||
teamName: 'team-alpha',
|
||||
description: 'Dev test team for provisioning flow',
|
||||
teamName: 'signal-ops',
|
||||
} as const;
|
||||
|
||||
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
|
||||
|
|
@ -134,6 +136,13 @@ function validateTeamNameInline(name: string): string | 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(
|
||||
request: TeamCreateRequest,
|
||||
options?: { requireCwd?: boolean }
|
||||
|
|
@ -224,6 +233,7 @@ export const CreateTeamDialog = ({
|
|||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
teamName?: string;
|
||||
members?: string;
|
||||
|
|
@ -313,6 +323,7 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const resetFormState = (): void => {
|
||||
setTeamName('');
|
||||
lastAutoDescriptionRef.current = null;
|
||||
descriptionDraft.clearDraft();
|
||||
promptDraft.clearDraft();
|
||||
promptChipDraft.clearChipDraft();
|
||||
|
|
@ -328,6 +339,7 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
||||
const suggestedTeamName = getNextSuggestedTeamName(existingTeamNames);
|
||||
|
||||
// Clear stale provisioning error when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -469,16 +481,34 @@ export const CreateTeamDialog = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialData) {
|
||||
return;
|
||||
}
|
||||
setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev));
|
||||
}, [initialData, open, suggestedTeamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isDev || initialData) {
|
||||
return;
|
||||
}
|
||||
setTeamName((prev) => (prev.trim().length === 0 ? DEV_DEFAULT_TEAM.teamName : prev));
|
||||
if (descriptionDraft.value.trim().length === 0) {
|
||||
descriptionDraft.setValue(DEV_DEFAULT_TEAM.description);
|
||||
const resolvedTeamName = teamName.trim() || suggestedTeamName;
|
||||
const nextAutoDescription = buildDefaultTeamDescription(resolvedTeamName);
|
||||
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)
|
||||
useEffect(() => {
|
||||
|
|
@ -501,27 +531,19 @@ export const CreateTeamDialog = ({
|
|||
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
|
||||
|
||||
const description = descriptionDraft.value;
|
||||
const prompt = promptDraft.value;
|
||||
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
const mentionSuggestions = useMemo(
|
||||
() =>
|
||||
soloTeam
|
||||
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
||||
: members
|
||||
.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, soloTeam]
|
||||
: buildMemberDraftSuggestions(members, memberColorMap),
|
||||
[memberColorMap, members, soloTeam]
|
||||
);
|
||||
|
||||
const effectiveModel = useMemo(
|
||||
|
|
@ -801,7 +823,7 @@ export const CreateTeamDialog = ({
|
|||
)}
|
||||
value={teamName}
|
||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||
placeholder="team-alpha"
|
||||
placeholder={suggestedTeamName}
|
||||
/>
|
||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||
|
|
@ -833,6 +855,8 @@ export const CreateTeamDialog = ({
|
|||
showJsonEditor
|
||||
draftKeyPrefix="createTeam"
|
||||
projectPath={effectiveCwd || null}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamMentionSuggestions}
|
||||
hideContent={soloTeam}
|
||||
headerExtra={
|
||||
<div className="space-y-2">
|
||||
|
|
@ -930,6 +954,8 @@ export const CreateTeamDialog = ({
|
|||
value={prompt}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={soloTeam ? [] : mentionSuggestions}
|
||||
teamSuggestions={teamMentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
projectPath={effectiveCwd || null}
|
||||
chips={promptChipDraft.chips}
|
||||
onChipRemove={promptChipDraft.removeChip}
|
||||
|
|
|
|||
67
src/renderer/components/team/dialogs/teamNameSets.ts
Normal file
67
src/renderer/components/team/dialogs/teamNameSets.ts
Normal 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 };
|
||||
|
|
@ -20,6 +20,7 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
|||
interface MemberDraftRowProps {
|
||||
member: MemberDraft;
|
||||
index: number;
|
||||
resolvedColor?: string;
|
||||
nameError: string | null;
|
||||
onNameChange: (id: string, name: string) => void;
|
||||
onRoleChange: (id: string, roleSelection: string) => void;
|
||||
|
|
@ -31,11 +32,14 @@ interface MemberDraftRowProps {
|
|||
draftKeyPrefix?: string;
|
||||
projectPath?: string | null;
|
||||
mentionSuggestions?: MentionSuggestion[];
|
||||
taskSuggestions?: MentionSuggestion[];
|
||||
teamSuggestions?: MentionSuggestion[];
|
||||
}
|
||||
|
||||
export const MemberDraftRow = ({
|
||||
member,
|
||||
index,
|
||||
resolvedColor,
|
||||
nameError,
|
||||
onNameChange,
|
||||
onRoleChange,
|
||||
|
|
@ -47,10 +51,12 @@ export const MemberDraftRow = ({
|
|||
draftKeyPrefix,
|
||||
projectPath,
|
||||
mentionSuggestions = [],
|
||||
taskSuggestions,
|
||||
teamSuggestions,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const memberColorSet = getTeamColorSet(
|
||||
getMemberColorByName(member.name.trim() || `member-${index}`)
|
||||
resolvedColor ?? getMemberColorByName(member.name.trim() || `member-${index}`)
|
||||
);
|
||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
|
@ -119,7 +125,7 @@ export const MemberDraftRow = ({
|
|||
|
||||
return (
|
||||
<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={{
|
||||
backgroundColor: isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||
|
|
@ -128,7 +134,7 @@ export const MemberDraftRow = ({
|
|||
}}
|
||||
>
|
||||
<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 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
@ -215,6 +221,8 @@ export const MemberDraftRow = ({
|
|||
value={workflowDraft.value}
|
||||
onValueChange={handleWorkflowChange}
|
||||
suggestions={suggestionsExcludingSelf}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
chips={chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath ?? undefined}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
||||
import { MemberDraftRow } from './MemberDraftRow';
|
||||
import { getNextSuggestedMemberName } from './memberNameSets';
|
||||
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
createMemberDraft,
|
||||
getMemberDraftRole,
|
||||
getWorkflowForExport,
|
||||
} from './membersEditorUtils';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -20,12 +25,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
|||
const arr = drafts
|
||||
.filter((d) => d.name.trim())
|
||||
.map((d) => {
|
||||
const role =
|
||||
d.roleSelection === CUSTOM_ROLE
|
||||
? d.customRole.trim() || undefined
|
||||
: d.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: d.roleSelection.trim() || undefined;
|
||||
const role = getMemberDraftRole(d);
|
||||
const obj: Record<string, string> = { name: d.name.trim() };
|
||||
if (role) obj.role = role;
|
||||
const workflow = getWorkflowForExport(d);
|
||||
|
|
@ -64,6 +64,10 @@ export interface MembersEditorSectionProps {
|
|||
draftKeyPrefix?: string;
|
||||
/** Project path for @file mentions in workflow */
|
||||
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 */
|
||||
headerExtra?: React.ReactNode;
|
||||
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
|
||||
|
|
@ -79,6 +83,8 @@ export const MembersEditorSection = ({
|
|||
showJsonEditor = true,
|
||||
draftKeyPrefix,
|
||||
projectPath,
|
||||
taskSuggestions,
|
||||
teamSuggestions,
|
||||
headerExtra,
|
||||
hideContent = false,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
|
|
@ -152,23 +158,11 @@ export const MembersEditorSection = ({
|
|||
|
||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
const hasDuplicates = new Set(names).size !== names.length;
|
||||
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members
|
||||
.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]
|
||||
const mentionSuggestions = useMemo(
|
||||
() => buildMemberDraftSuggestions(members, memberColorMap),
|
||||
[members, memberColorMap]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -198,6 +192,7 @@ export const MembersEditorSection = ({
|
|||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
resolvedColor={memberColorMap.get(member.name.trim())}
|
||||
nameError={validateMemberName?.(member.name) ?? null}
|
||||
onNameChange={updateMemberName}
|
||||
onRoleChange={updateMemberRole}
|
||||
|
|
@ -209,6 +204,8 @@ export const MembersEditorSection = ({
|
|||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
mentionSuggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
/>
|
||||
))}
|
||||
{jsonEditorOpen && showJsonEditor ? (
|
||||
|
|
@ -237,7 +234,10 @@ export const MembersEditorSection = ({
|
|||
|
||||
export type { MemberDraft } from './membersEditorTypes';
|
||||
export {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
getMemberDraftRole,
|
||||
validateMemberNameInline,
|
||||
} from './membersEditorUtils';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { TeamProvisioningMemberInput } from '@shared/types';
|
||||
|
||||
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. */
|
||||
export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
||||
const workflowRaw = member.workflow?.trim();
|
||||
|
|
@ -50,13 +87,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
member.roleSelection === CUSTOM_ROLE
|
||||
? member.customRole.trim() || undefined
|
||||
: member.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: member.roleSelection.trim() || undefined;
|
||||
|
||||
const role = getMemberDraftRole(member);
|
||||
const result: TeamProvisioningMemberInput = { name, role };
|
||||
const workflow = getWorkflowForExport(member);
|
||||
if (workflow) result.workflow = workflow;
|
||||
|
|
|
|||
|
|
@ -164,7 +164,9 @@ export const MentionSuggestionList = ({
|
|||
<UsersRound
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }}
|
||||
style={{
|
||||
color: colorSet ? getThemedText(colorSet, isLight) : 'var(--color-text-muted)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -180,7 +182,7 @@ export const MentionSuggestionList = ({
|
|||
isTask
|
||||
? { color: 'var(--color-link, #60a5fa)' }
|
||||
: colorSet
|
||||
? { color: colorSet.text }
|
||||
? { color: getThemedText(colorSet, isLight) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ interface TaskSegment {
|
|||
value: string;
|
||||
suggestion: MentionSuggestion;
|
||||
encoded: boolean;
|
||||
/** Zero-width metadata chars rendered in backdrop for caret alignment */
|
||||
hiddenSuffix?: string;
|
||||
}
|
||||
|
||||
interface UrlSegment {
|
||||
|
|
@ -197,11 +199,16 @@ function parseSuggestionSegments(
|
|||
if (match.start > lastEnd) {
|
||||
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({
|
||||
type: 'task',
|
||||
value: match.raw,
|
||||
suggestion: match.suggestion,
|
||||
encoded: match.encoded,
|
||||
hiddenSuffix,
|
||||
});
|
||||
lastEnd = match.end;
|
||||
}
|
||||
|
|
@ -987,25 +994,26 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}
|
||||
if (seg.type === 'task') {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={
|
||||
seg.encoded
|
||||
? 'rounded px-1.5 py-0.5 font-medium'
|
||||
: 'font-medium underline decoration-transparent'
|
||||
}
|
||||
style={
|
||||
seg.encoded
|
||||
? {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
color: PROSE_LINK,
|
||||
boxShadow: '0 0 0 1.5px rgba(59, 130, 246, 0.15)',
|
||||
}
|
||||
: { color: PROSE_LINK }
|
||||
}
|
||||
>
|
||||
{seg.value}
|
||||
</span>
|
||||
<React.Fragment key={idx}>
|
||||
<span
|
||||
className={seg.encoded ? 'rounded' : 'underline decoration-transparent'}
|
||||
style={
|
||||
seg.encoded
|
||||
? {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
color: PROSE_LINK,
|
||||
// Only vertical padding (doesn't affect inline text flow).
|
||||
// No horizontal padding/margin/box-shadow spread to avoid
|
||||
// caret drift or visual overlap with adjacent text.
|
||||
padding: '2px 0',
|
||||
}
|
||||
: { color: PROSE_LINK }
|
||||
}
|
||||
>
|
||||
{seg.value}
|
||||
</span>
|
||||
{seg.hiddenSuffix}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
if (seg.type === 'url') {
|
||||
|
|
@ -1121,16 +1129,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
</div>
|
||||
|
||||
{showFooter ? (
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<div className="mt-1 flex items-start justify-between gap-2">
|
||||
{showHintRow ? (
|
||||
<span
|
||||
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300"
|
||||
style={{ opacity: tipVisible ? 1 : 0 }}
|
||||
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, maxHeight: '1.5rem' }}
|
||||
>
|
||||
{resolvedHintText}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
<span className="min-h-6 flex-1" />
|
||||
)}
|
||||
{footerRight}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* Used by TeammateMessageItem and SubagentItem when displaying team members.
|
||||
*/
|
||||
|
||||
import { MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
|
||||
|
||||
export interface TeamColorSet {
|
||||
/** Border accent color */
|
||||
border: string;
|
||||
|
|
@ -128,6 +130,33 @@ export function getSubagentTypeColorSet(
|
|||
/** Assignable visual colors (excludes reserved '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 {
|
||||
if (!colorName) return DEFAULT_COLOR;
|
||||
|
||||
|
|
@ -135,6 +164,9 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
|
|||
const named = TEAMMATE_COLORS[colorName.toLowerCase()];
|
||||
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 (colorName.startsWith('#')) {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in a new issue