diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
index 9029bbda79e..c3189d4d50a 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
@@ -1,33 +1,24 @@
'use client'
-import { type ReactNode, useState } from 'react'
-import {
- Chip,
- ChipConfirmModal,
- ChipInput,
- ChipLink,
- ChipTextarea,
- chipFieldSurfaceClass,
- cn,
- Send,
- Tooltip,
- toast,
-} from '@sim/emcn'
+import { useState } from 'react'
+import { Chip, ChipConfirmModal, ChipLink, Send, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { AddPeopleModal } from '@/components/permissions'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import {
CredentialDetailHeading,
CredentialDetailLayout,
- DetailSection,
UnsavedChangesModal,
useUnsavedChangesGuard,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card'
+import {
+ type SkillFieldErrors,
+ SkillFields,
+} from '@/app/workspace/[workspaceId]/skills/components/skill-fields'
import { useSkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members'
import {
isSkillNameConflictError,
@@ -36,47 +27,8 @@ import {
} from '@/app/workspace/[workspaceId]/skills/components/utils'
import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills'
-const RichMarkdownField = dynamic(
- () =>
- import(
- '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
- ).then((m) => m.RichMarkdownField),
- {
- ssr: false,
- loading: () =>
,
- }
-)
-
const logger = createLogger('SkillDetail')
-interface FieldErrors {
- name?: string
- description?: string
- content?: string
-}
-
-interface FieldLockTooltipProps {
- reason: string | null
- children: ReactNode
-}
-
-/**
- * Wraps a read-only field so hovering it explains why editing is locked.
- * Renders children unchanged when the field is editable. The wrapper div
- * receives the hover events a disabled control swallows.
- */
-function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) {
- if (!reason) return <>{children}>
- return (
-
-
- {children}
-
- {reason}
-
- )
-}
-
interface SkillDetailProps {
workspaceId: string
skillId: string
@@ -110,7 +62,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
const [contentDraft, setContentDraft] = useState('')
/** Bumped to remount the seed-once rich Content editor on programmatic sets. */
const [contentSeed, setContentSeed] = useState(0)
- const [errors, setErrors] = useState({})
+ const [errors, setErrors] = useState({})
const [shareOpen, setShareOpen] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [prevSkillId, setPrevSkillId] = useState(null)
@@ -138,7 +90,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
const handleSave = async () => {
if (!skill || !canEdit || !isDirty || updateSkill.isPending) return
- const newErrors: FieldErrors = {}
+ const newErrors: SkillFieldErrors = {}
const nameError = validateSkillName(nameDraft)
if (nameError) newErrors.name = nameError
if (!descriptionDraft.trim()) newErrors.description = 'Description is required'
@@ -252,70 +204,29 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
subtitle={isBuiltin ? 'Built-in skill' : skill.description}
/>
-
-
- {
- setNameDraft(event.target.value)
- if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
- }}
- placeholder='my-skill-name'
- autoComplete='off'
- data-lpignore='true'
- disabled={readOnly}
- error={!!errors.name}
- />
-
- {errors.name && (
- {errors.name}
- )}
-
-
-
-
- {
- setDescriptionDraft(event.target.value)
- if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
- }}
- placeholder='What this skill does and when to use it...'
- maxLength={1024}
- autoComplete='off'
- data-lpignore='true'
- disabled={readOnly}
- />
-
- {errors.description && (
- {errors.description}
- )}
-
-
-
-
- {
- setContentDraft(value)
- if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
- }}
- placeholder='Skill instructions in markdown...'
- minHeight={260}
- disabled={readOnly}
- error={!!errors.content}
- workspaceId={workspaceId}
- onPasteText={handleContentPaste}
- />
-
- {errors.content && (
- {errors.content}
- )}
-
+ {
+ setNameDraft(value)
+ if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
+ }}
+ onDescriptionChange={(value) => {
+ setDescriptionDraft(value)
+ if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
+ }}
+ onContentChange={(value) => {
+ setContentDraft(value)
+ if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
+ }}
+ errors={errors}
+ contentKey={`${skill.id}:${contentSeed}`}
+ workspaceId={workspaceId}
+ disabled={readOnly}
+ lockReason={lockReason}
+ onPasteText={handleContentPaste}
+ />
{!isBuiltin && }
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts
new file mode 100644
index 00000000000..6c3ae2f63fb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts
@@ -0,0 +1,14 @@
+/**
+ * Field copy shared by every skill editing surface. The two pages share JSX via
+ * `SkillFields`, but the canvas modal must frame its fields with
+ * `ChipModalField` — required inside a `ChipModalBody` — so it cannot. These
+ * strings are what keep the modal in step with the pages.
+ */
+
+export const SKILL_NAME_PLACEHOLDER = 'my-skill-name'
+export const SKILL_NAME_HINT = 'Lowercase letters, numbers, and hyphens (e.g. my-skill)'
+export const SKILL_DESCRIPTION_PLACEHOLDER = 'What this skill does and when to use it...'
+export const SKILL_CONTENT_PLACEHOLDER = 'Skill instructions in markdown...'
+
+/** Mirrors `skillDescriptionSchema` in the contract. */
+export const SKILL_DESCRIPTION_MAX_LENGTH = 1024
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts
new file mode 100644
index 00000000000..257dcb31ce0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts
@@ -0,0 +1,4 @@
+export {
+ type SkillFieldErrors,
+ SkillFields,
+} from '@/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields'
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx
new file mode 100644
index 00000000000..c865528ad2a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx
@@ -0,0 +1,166 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { ChipInput, ChipTextarea, chipFieldSurfaceClass, cn, Tooltip } from '@sim/emcn'
+import dynamic from 'next/dynamic'
+import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail'
+import {
+ SKILL_CONTENT_PLACEHOLDER,
+ SKILL_DESCRIPTION_MAX_LENGTH,
+ SKILL_DESCRIPTION_PLACEHOLDER,
+ SKILL_NAME_HINT,
+ SKILL_NAME_PLACEHOLDER,
+} from '@/app/workspace/[workspaceId]/skills/components/skill-copy'
+
+const RichMarkdownField = dynamic(
+ () =>
+ import(
+ '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
+ ).then((m) => m.RichMarkdownField),
+ {
+ ssr: false,
+ loading: () => ,
+ }
+)
+
+export interface SkillFieldErrors {
+ name?: string
+ description?: string
+ content?: string
+}
+
+interface SkillFieldsProps {
+ name: string
+ description: string
+ content: string
+ onNameChange: (value: string) => void
+ onDescriptionChange: (value: string) => void
+ onContentChange: (value: string) => void
+ errors: SkillFieldErrors
+ /** Remounts the seed-once rich editor when content is set programmatically. */
+ contentKey: string | number
+ workspaceId: string
+ disabled?: boolean
+ /**
+ * Why the fields are locked (built-in skill, or viewer is not an editor).
+ * Renders a tooltip explaining it — a disabled control swallows hover, so each
+ * field is wrapped rather than the control itself.
+ */
+ lockReason?: string | null
+ /** Intercepts a full SKILL.md paste into Content. */
+ onPasteText?: (text: string) => boolean
+}
+
+interface FieldLockTooltipProps {
+ reason: string | null | undefined
+ children: ReactNode
+}
+
+function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) {
+ if (!reason) return <>{children}>
+ return (
+
+
+ {children}
+
+ {reason}
+
+ )
+}
+
+/**
+ * The hint-or-error line under a field. Renders nothing when there is neither,
+ * so a field without a message reserves no space.
+ */
+function FieldMessage({ error, hint }: { error?: string; hint?: string }) {
+ const message = error ?? hint
+ if (!message) return null
+ return (
+
+ {message}
+
+ )
+}
+
+/**
+ * The Name / Description / Content trio as the full-page skill surfaces render
+ * it — `DetailSection` rows with the error line beneath each field. Shared by
+ * the create and detail pages so the copy, sizing, and error treatment cannot
+ * drift between them. The canvas modal frames the same fields with
+ * `ChipModalField` (required inside a `ChipModalBody`) and shares the copy
+ * constants instead.
+ */
+export function SkillFields({
+ name,
+ description,
+ content,
+ onNameChange,
+ onDescriptionChange,
+ onContentChange,
+ errors,
+ contentKey,
+ workspaceId,
+ disabled = false,
+ lockReason,
+ onPasteText,
+}: SkillFieldsProps) {
+ return (
+ <>
+
+
+ onNameChange(event.target.value)}
+ placeholder={SKILL_NAME_PLACEHOLDER}
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={disabled}
+ error={!!errors.name}
+ />
+
+
+
+
+
+
+ onDescriptionChange(event.target.value)}
+ placeholder={SKILL_DESCRIPTION_PLACEHOLDER}
+ maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={disabled}
+ error={!!errors.description}
+ />
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
index cd6d8d29f78..12e97e2d71c 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
@@ -15,6 +15,13 @@ import {
import { getErrorMessage } from '@sim/utils/errors'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
+import {
+ SKILL_CONTENT_PLACEHOLDER,
+ SKILL_DESCRIPTION_MAX_LENGTH,
+ SKILL_DESCRIPTION_PLACEHOLDER,
+ SKILL_NAME_HINT,
+ SKILL_NAME_PLACEHOLDER,
+} from '@/app/workspace/[workspaceId]/skills/components/skill-copy'
import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import'
import {
isSkillNameConflictError,
@@ -73,7 +80,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
*/
const [contentSeed, setContentSeed] = useState(0)
const [errors, setErrors] = useState({})
- const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState('create')
const [prevOpen, setPrevOpen] = useState(false)
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
@@ -96,6 +102,8 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
description !== initialValues.description ||
content !== initialValues.content
+ const saving = createSkill.isPending || updateSkill.isPending
+
const handleSave = async () => {
const newErrors: FieldErrors = {}
@@ -115,8 +123,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
return
}
- setSaving(true)
-
try {
if (initialValues) {
await updateSkill.mutateAsync({
@@ -137,8 +143,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
} else {
setErrors({ general: 'Failed to save skill. Please try again.' })
}
- } finally {
- setSaving(false)
}
}
@@ -205,10 +209,10 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
if (errors.name || errors.general)
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
}}
- placeholder='my-skill-name'
+ placeholder={SKILL_NAME_PLACEHOLDER}
required
error={errors.name}
- hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)'
+ hint={SKILL_NAME_HINT}
disabled={readOnly || saving}
/>
@@ -221,8 +225,8 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
if (errors.description || errors.general)
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
}}
- placeholder='What this skill does and when to use it...'
- maxLength={1024}
+ placeholder={SKILL_DESCRIPTION_PLACEHOLDER}
+ maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
required
error={errors.description}
disabled={readOnly || saving}
@@ -237,7 +241,7 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM
if (errors.content || errors.general)
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
}}
- placeholder='Skill instructions in markdown...'
+ placeholder={SKILL_CONTENT_PLACEHOLDER}
minHeight={200}
maxHeight={360}
disabled={readOnly || saving}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
index 6c4a246f47b..928862b063b 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
@@ -1,28 +1,22 @@
'use client'
import { useState } from 'react'
-import {
- Chip,
- ChipInput,
- ChipLink,
- ChipTextarea,
- chipFieldSurfaceClass,
- cn,
- toast,
-} from '@sim/emcn'
+import { Chip, ChipLink, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import {
CredentialDetailHeading,
CredentialDetailLayout,
- DetailSection,
UnsavedChangesModal,
useUnsavedChangesGuard,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import {
+ type SkillFieldErrors,
+ SkillFields,
+} from '@/app/workspace/[workspaceId]/skills/components/skill-fields'
import { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import'
import {
isSkillNameConflictError,
@@ -32,25 +26,8 @@ import {
} from '@/app/workspace/[workspaceId]/skills/components/utils'
import { useCreateSkill } from '@/hooks/queries/skills'
-const RichMarkdownField = dynamic(
- () =>
- import(
- '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
- ).then((m) => m.RichMarkdownField),
- {
- ssr: false,
- loading: () => ,
- }
-)
-
const logger = createLogger('SkillCreate')
-interface FieldErrors {
- name?: string
- description?: string
- content?: string
-}
-
interface SkillCreateProps {
workspaceId: string
}
@@ -71,7 +48,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
const [contentDraft, setContentDraft] = useState('')
/** Bumped to remount the seed-once rich Content editor on programmatic sets. */
const [contentSeed, setContentSeed] = useState(0)
- const [errors, setErrors] = useState({})
+ const [errors, setErrors] = useState({})
// Drops on success so the guard pops its history sentinel before we navigate —
// otherwise Back from the new skill lands on a stale, empty create form.
@@ -84,7 +61,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
const handleCreate = async () => {
if (createSkill.isPending) return
- const newErrors: FieldErrors = {}
+ const newErrors: SkillFieldErrors = {}
const nameError = validateSkillName(nameDraft)
if (nameError) newErrors.name = nameError
if (!descriptionDraft.trim()) newErrors.description = 'Description is required'
@@ -161,70 +138,28 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
subtitle='Write a skill, or import an existing SKILL.md'
/>
-
- {
- setNameDraft(event.target.value)
- if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
- }}
- placeholder='my-skill-name'
- autoComplete='off'
- data-lpignore='true'
- disabled={createSkill.isPending}
- error={!!errors.name}
- />
-
- {errors.name ?? 'Lowercase letters, numbers, and hyphens (e.g. my-skill)'}
-
-
-
-
- {
- setDescriptionDraft(event.target.value)
- if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
- }}
- placeholder='What this skill does and when to use it...'
- maxLength={1024}
- autoComplete='off'
- data-lpignore='true'
- disabled={createSkill.isPending}
- error={!!errors.description}
- />
- {errors.description && (
- {errors.description}
- )}
-
-
-
- {
- setContentDraft(value)
- if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
- }}
- placeholder='Skill instructions in markdown...'
- minHeight={260}
- disabled={createSkill.isPending}
- error={!!errors.content}
- workspaceId={workspaceId}
- onPasteText={handleContentPaste}
- />
- {errors.content && (
- {errors.content}
- )}
-
+ {
+ setNameDraft(value)
+ if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
+ }}
+ onDescriptionChange={(value) => {
+ setDescriptionDraft(value)
+ if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
+ }}
+ onContentChange={(value) => {
+ setContentDraft(value)
+ if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
+ }}
+ errors={errors}
+ contentKey={contentSeed}
+ workspaceId={workspaceId}
+ disabled={createSkill.isPending}
+ onPasteText={handleContentPaste}
+ />