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} + />