diff --git a/api/controllers/console/workspace/skills.py b/api/controllers/console/workspace/skills.py index 02dce96e3e0..65199bee719 100644 --- a/api/controllers/console/workspace/skills.py +++ b/api/controllers/console/workspace/skills.py @@ -87,6 +87,8 @@ class SkillResponse(ResponseModel): name_manually_edited: bool = False visibility: str latest_published_version_id: str | None = None + latest_published_version_number: int | None = None + latest_published_at: int | None = None reference_count: int = 0 created_by: str | None = None created_by_name: str | None = None diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index 484b6f07e04..3fe2962001c 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -31,6 +31,7 @@ import yaml from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import object_session from yaml.error import MarkedYAMLError from core.db.session_factory import session_factory @@ -1949,6 +1950,15 @@ class SkillManagementService: accounts = accounts or {} created_by_account = accounts.get(skill.created_by or "") updated_by_account = accounts.get(skill.updated_by or "") + latest_published_version_number: int | None = None + latest_published_at: int | None = None + session = object_session(skill) + if session is not None and skill.latest_published_version_id is not None: + latest_version = session.get(SkillVersion, skill.latest_published_version_id) + if latest_version is not None: + latest_published_version_number = latest_version.version_number + latest_published_at = int(latest_version.created_at.timestamp()) + return { "id": skill.id, "name": skill.name, @@ -1959,6 +1969,8 @@ class SkillManagementService: "name_manually_edited": skill.name_manually_edited, "visibility": skill.visibility, "latest_published_version_id": skill.latest_published_version_id, + "latest_published_version_number": latest_published_version_number, + "latest_published_at": latest_published_at, "reference_count": reference_count, "created_by": skill.created_by, "created_by_name": created_by_account.name if created_by_account else None, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index d11f00b743d..a3e1516a7cb 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -671,7 +671,9 @@ export type SkillDetailResponse = { files?: Array icon: string id: string + latest_published_at?: number | null latest_published_version_id?: string | null + latest_published_version_number?: number | null name: string name_manually_edited?: boolean reference_count?: number @@ -718,7 +720,9 @@ export type SkillResponse = { display_name: string icon: string id: string + latest_published_at?: number | null latest_published_version_id?: string | null + latest_published_version_number?: number | null name: string name_manually_edited?: boolean reference_count?: number diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index f073d11f3ed..8feb6c2cef8 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -502,7 +502,9 @@ export const zSkillResponse = z.object({ display_name: z.string(), icon: z.string(), id: z.string(), + latest_published_at: z.int().nullish(), latest_published_version_id: z.string().nullish(), + latest_published_version_number: z.int().nullish(), name: z.string(), name_manually_edited: z.boolean().optional().default(false), reference_count: z.int().optional().default(0), @@ -1455,7 +1457,9 @@ export const zSkillDetailResponse = z.object({ files: z.array(zSkillFileResponse).optional(), icon: z.string(), id: z.string(), + latest_published_at: z.int().nullish(), latest_published_version_id: z.string().nullish(), + latest_published_version_number: z.int().nullish(), name: z.string(), name_manually_edited: z.boolean().optional().default(false), reference_count: z.int().optional().default(0), diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index dbd66585f35..e2e6495187b 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -210,6 +210,8 @@ function createSkillDetail(overrides: Partial = {}): SkillD name_manually_edited: true, visibility: 'workspace', latest_published_version_id: 'version-1', + latest_published_version_number: 1, + latest_published_at: 1784638400, reference_count: 0, created_by: 'user-1', created_by_name: 'Fate', @@ -461,17 +463,29 @@ describe('SkillDetailPage', () => { return nextDetail }, ) - mocks.publishSkillMutationFn.mockResolvedValue({ - id: 'version-2', - version_number: 2, - version_name: '', - publish_note: '', - hash_code: 'hash-code', - archive_size: 180, - published_by: 'user-1', - published_by_name: 'Fate', - created_at: 1784638492, - is_latest: true, + mocks.publishSkillMutationFn.mockImplementation(async () => { + const version = { + id: 'version-2', + version_number: 2, + version_name: '', + publish_note: '', + hash_code: 'hash-code', + archive_size: 180, + published_by: 'user-1', + published_by_name: 'Fate', + created_at: 1784638492, + is_latest: true, + } + mocks.skillDetail = mocks.skillDetail + ? { + ...mocks.skillDetail, + latest_published_at: version.created_at, + latest_published_version_id: version.id, + latest_published_version_number: version.version_number, + updated_at: version.created_at, + } + : mocks.skillDetail + return version }) mocks.restoreSkillMutationFn.mockResolvedValue({}) mocks.versionPatchMutationFn.mockResolvedValue({}) @@ -731,6 +745,34 @@ describe('SkillDetailPage', () => { }) }) + it('marks the draft as published and disables publish until new edits are made', async () => { + const user = userEvent.setup() + renderSkillDetailPage() + + const publishButton = await screen.findByRole('button', { + name: 'agentV2.skillManagement.detail.publish', + }) + expect(publishButton).toBeEnabled() + + await user.click(publishButton) + + await waitFor(() => { + expect(mocks.publishSkillMutationFn).toHaveBeenCalled() + }) + await waitFor(() => { + expect(document.body).toHaveTextContent( + 'agentV2.skillManagement.detail.publishedVersion:{"number":2}', + ) + }) + expect(publishButton).toBeDisabled() + + const displayNameInput = screen.getByDisplayValue('Untitled skill') + await user.clear(displayNameInput) + await user.type(displayNameInput, 'Updated skill') + + expect(publishButton).toBeEnabled() + }) + it('adds custom metadata from the value field Enter key and saves it on publish', async () => { const user = userEvent.setup() renderSkillDetailPage() diff --git a/web/features/skills/detail-page.tsx b/web/features/skills/detail-page.tsx index 2cffd9d0ab9..053d7a01908 100644 --- a/web/features/skills/detail-page.tsx +++ b/web/features/skills/detail-page.tsx @@ -4170,6 +4170,8 @@ function FileEditor({ detail, file, fileMutationCoordinator, + hasLocalUnpublishedChanges, + onLocalUnpublishedChangesChange, onOpenVersions, onPublish, onRestoreVersion, @@ -4188,6 +4190,8 @@ function FileEditor({ detail: SkillDetailResponse | undefined file: SkillFileResponse | undefined fileMutationCoordinator: SkillFileMutationCoordinator + hasLocalUnpublishedChanges: boolean + onLocalUnpublishedChangesChange: (hasChanges: boolean) => void onOpenVersions: () => void onPublish: () => void onRestoreVersion: () => void @@ -4231,6 +4235,7 @@ function FileEditor({ const saveConflictContentRef = useRef(null) const detailRef = useRef(detail) const fileRef = useRef(file) + const pendingPublishAfterSaveRef = useRef(false) const liveBodyTextareaRef = useRef(null) const liveBodyEditorRef = useRef(null) const sourceTextareaRef = useRef(null) @@ -4264,6 +4269,29 @@ function FileEditor({ [draftContent, isSkillManifestFile], ) const csvRows = useMemo(() => parseCsvRows(draftContent), [draftContent]) + const hasPublishedVersion = !!detail?.latest_published_version_id + const latestPublishedVersionNumber = detail?.latest_published_version_number + const latestPublishedVersionText = + typeof latestPublishedVersionNumber === 'number' + ? t(($) => $['skillManagement.detail.publishedVersion'], { + number: latestPublishedVersionNumber, + }) + : null + const latestPublishedAt = detail?.latest_published_at + const hasUnpublishedChanges = + saveStatus === 'dirty' || + saveStatus === 'saving' || + saveStatus === 'error' || + hasSaveConflict || + hasLocalUnpublishedChanges || + !hasPublishedVersion || + (typeof detail?.updated_at === 'number' && + typeof latestPublishedAt === 'number' && + detail.updated_at > latestPublishedAt) + const publishStatusText = hasUnpublishedChanges + ? t(($) => $['skillManagement.detail.draft']) + : (latestPublishedVersionText ?? t(($) => $['skillManagement.detail.published'])) + const publishDisabled = publishing || !hasUnpublishedChanges const fileHash = file?.hash const editorInstanceKey = `${selectedVersionId ?? 'draft'}:${filePath ?? 'empty'}:${readonly ? 'readonly' : 'draft'}` const editorRenderKey = `${editorInstanceKey}:${externalContentRevision}` @@ -4596,16 +4624,20 @@ function FileEditor({ setReferenceSelectedIndex(Math.max(filteredReferenceFiles.length - 1, 0)) }, [filteredReferenceFiles.length, referenceSelectedIndex]) - const updateDraftContent = (nextContent: string) => { - draftContentRef.current = nextContent - const isConflictContent = nextContent === saveConflictContentRef.current - if (!isConflictContent) { - saveConflictContentRef.current = null - setHasSaveConflict(false) - } - setDraftContent(nextContent) - setSaveStatus(nextContent === lastSavedContentRef.current ? 'saved' : 'dirty') - } + const updateDraftContent = useCallback( + (nextContent: string) => { + draftContentRef.current = nextContent + const isConflictContent = nextContent === saveConflictContentRef.current + if (!isConflictContent) { + saveConflictContentRef.current = null + setHasSaveConflict(false) + } + setDraftContent(nextContent) + setSaveStatus(nextContent === lastSavedContentRef.current ? 'saved' : 'dirty') + if (nextContent !== lastSavedContentRef.current) onLocalUnpublishedChangesChange(true) + }, + [onLocalUnpublishedChangesChange], + ) const handleContentChange = (event: ChangeEvent) => { const nextContent = event.target.value @@ -4922,8 +4954,12 @@ function FileEditor({ setMetadataAdding(false) } - const handlePublish = async () => { - if (publishing) return + const handlePublish = useCallback(async () => { + if (publishDisabled) return + if (saveStatus === 'saving') { + pendingPublishAfterSaveRef.current = true + return + } let contentToPublish = draftContentRef.current if (canEdit && isSkillManifestFile && displayNameDraft !== markdownContent.displayName) { @@ -4942,7 +4978,25 @@ function FileEditor({ } onPublish() - } + }, [ + canEdit, + detail?.reference_count, + displayNameDraft, + isSkillManifestFile, + markdownContent.displayName, + onPublish, + publishDisabled, + saveDraftContent, + saveStatus, + updateDraftContent, + ]) + + useEffect(() => { + if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return + + pendingPublishAfterSaveRef.current = false + void handlePublish() + }, [handlePublish, saveStatus]) const saveStateText = saveStatus === 'saving' @@ -5419,7 +5473,13 @@ function FileEditor({ /> - {t(($) => $['skillManagement.detail.draft'])} + {publishStatusText} + {hasUnpublishedChanges && latestPublishedVersionText && ( + <> + · + {latestPublishedVersionText} + + )} · {saveStateText} @@ -5434,8 +5494,8 @@ function FileEditor({