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.
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}` : '',

View file

@ -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}

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 {
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}

View file

@ -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';

View file

@ -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;

View file

@ -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
}
>

View file

@ -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>

View file

@ -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 {