This commit is contained in:
Zhao Jianing 2026-11-04 03:30:31 +00:00 • committed by GitHub
commit e30fc43004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1548 additions and 71 deletions

View file

@ -1,6 +1,12 @@
# 草稿与结构校验 API
这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。尚不包含网页工作台、模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。
这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。网页工作台位于 `/dashboard/authoring`:创建、删除草稿,编辑文本或上传二进制资源,发起结构校验并查看运行状态与问题。尚不包含模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。
## 网页工作台
登录后从个人中心的“技能创作”进入。文件保存携带读取时的草稿 revision,遇到并发冲突保留本地编辑;重新加载或切换文件前确认放弃未保存修改。创建同名文件不会用空内容覆盖已有资源。文本是否可编辑以读取接口的 `encoding` 为准,二进制资源只显示元数据并通过上传替换。
未保存修改时不能发起校验。运行状态和 findings 通过轮询更新;活动运行支持取消,终态后停止轮询并刷新草稿校验状态。问题面板显示规则、消息及文件位置,作者可手动编辑后重新校验。结构检查通过不等于脚本或模型行为已验证。
## 权限与文件

View file

@ -1,62 +1,64 @@
{
"schema": 1,
"description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.",
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":29,"forwardingSites":36},
"counts": {"routes":56,"uniqueIds":805,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":30,"forwardingSites":36},
"routes": [
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false},
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false},
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false},
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false},
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":276},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":287},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":308},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":324},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":341},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":356},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":363},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":370},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":380},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":390},"implementation":{"file":"web/src/pages/dashboard.tsx","line":99,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":397},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":410},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":436},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":443},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":453},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":469},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":487},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":497},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":509},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":516},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":523},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":530},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":537},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":547},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":554},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":561},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":594},"implementation":null,"guarded":true,"roleProtected":false},
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":603},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":613},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":635},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":642},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":649},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":656},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":663},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":673},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":683},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":690},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
{"name":"authoringDraftsRoute","path":"dashboard/authoring","declaration":{"file":"web/src/app/router.tsx","line":697},"implementation":{"file":"web/src/pages/authoring/drafts.tsx","line":46,"export":"DraftsPage"},"guarded":true,"roleProtected":false},
{"name":"authoringDraftDetailRoute","path":"dashboard/authoring/$draftId","declaration":{"file":"web/src/app/router.tsx","line":704},"implementation":{"file":"web/src/pages/authoring/draft-detail.tsx","line":15,"export":"DraftDetailPage"},"guarded":true,"roleProtected":false},
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":711},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":732},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":739},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":746},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":753},"implementation":null,"guarded":true,"roleProtected":false},
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":762},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":769},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":776},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":783},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":790},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
],
"definitions": [
{"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]},
@ -78,16 +80,17 @@
{"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]},
{"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]},
{"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]},
{"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]},
{"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]},
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]},
{"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]},
{"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]},
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]},
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]},
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]},
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]},
{"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]}
{"file":"web/src/pages/dashboard.tsx","line":55,"ids":["navigation.authoring.open"]},
{"file":"web/src/pages/dashboard.tsx","line":56,"ids":["review.progress.open"]},
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["governance.center.open"]},
{"file":"web/src/pages/dashboard.tsx","line":63,"ids":["report.queue.open"]},
{"file":"web/src/pages/dashboard.tsx","line":64,"ids":["github.sync.open"]},
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.skills"]},
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.owned.suites"]},
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.tokens.open"]},
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["navigation.starred.open"]},
{"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.profile.open"]},
{"file":"web/src/pages/dashboard.tsx","line":93,"ids":["account.security.open"]}
],
"sites": [
{"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
@ -758,10 +761,10 @@
{"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]},
{"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]},
{"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]},
{"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
{"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
{"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
{"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
{"file":"web/src/pages/dashboard.tsx","line":129,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
{"file":"web/src/pages/dashboard.tsx","line":177,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
{"file":"web/src/pages/dashboard.tsx","line":195,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
{"file":"web/src/pages/dashboard.tsx","line":246,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
{"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]},
{"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]},
{"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]},

View file

@ -0,0 +1,93 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
import { csrfHeaders } from './helpers/csrf'
test('authoring routes require a session', async ({ page }) => {
await setEnglishLocale(page)
await page.goto('/dashboard/authoring')
await expect(page).toHaveURL(/\/login\?returnTo=/)
})
test('creates a draft, edits files and rechecks structure using merged APIs', async ({ page }, testInfo) => {
await setEnglishLocale(page)
await setUniqueClientIp(page, testInfo.title)
await registerSession(page, testInfo, { allowMockSession: false })
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
const namespace = await builder.ensureWritableNamespace()
const name = `structure-${Date.now().toString(36)}`
let draftId: number | undefined
const authoringRequests: string[] = []
page.on('request', request => { if (request.url().includes('/authoring/')) authoringRequests.push(request.url()) })
try {
await page.goto('/dashboard/authoring')
await page.getByTestId('create-draft').click()
await page.getByTestId('draft-namespace').click()
await page.getByRole('option', { name: namespace.displayName || namespace.slug, exact: true }).click()
await page.getByLabel('Skill name', { exact: true }).fill(name)
const createdResponse = page.waitForResponse(response => response.url().endsWith('/api/web/authoring/drafts') && response.request().method() === 'POST')
await page.getByTestId('submit-create-draft').click()
const created = await (await createdResponse).json()
draftId = created.data.id
expect(draftId).toBeDefined()
await page.getByRole('link', { name, exact: true }).click()
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
const editor = page.getByLabel('File content', { exact: true })
await expect(editor).toBeVisible()
await editor.fill(`---\nname: ${name}\n---\n\n# Structure test\n`)
await expect(page.getByTestId('start-run')).toBeDisabled()
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByTestId('start-run')).toBeEnabled()
await page.getByTestId('start-run').click()
await expect(page.getByTestId('validation-results').getByText(/Failed · revision/)).toBeVisible({ timeout: 30_000 })
await expect(page.getByText(/FRONTMATTER_FIELD_MISSING/)).toBeVisible()
await editor.fill(`---\nname: ${name}\ndescription: A structure validation test skill\n---\n\n# Structure test\n\nUse this skill to test package validation.\n`)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByTestId('start-run')).toBeEnabled()
await page.getByTestId('start-run').click()
await expect(page.getByTestId('validation-results').getByText(/Succeeded · revision/)).toBeVisible({ timeout: 30_000 })
await expect(page.getByText(/Structure checked/)).toBeVisible()
// A successful structural check must not introduce runtime, fix or publish actions.
expect(authoringRequests.some(url => /\/(runtime|submit|apply|dismiss)(?:\?|$)/.test(url))).toBe(false)
const persisted = await page.request.get(`/api/web/authoring/drafts/${draftId}`)
expect((await persisted.json()).data.validated).toBe(true)
// Transport encoding, rather than filename guessing, keeps binary bytes out of the editor.
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64')
await page.getByLabel('Path', { exact: true }).fill('assets/pixel.png')
await page.getByLabel('Upload file', { exact: true }).setInputFiles({ name: 'pixel.png', mimeType: 'image/png', buffer: png })
await page.getByRole('button', { name: 'Upload', exact: true }).click()
await expect(page.getByTestId('binary-file-panel')).toBeVisible()
const binary = await page.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=assets%2Fpixel.png`)
const binaryFile = (await binary.json()).data
expect(binaryFile.encoding).toBe('base64')
expect(Buffer.from(binaryFile.content, 'base64').equals(png)).toBe(true)
const invalidated = await page.request.get(`/api/web/authoring/drafts/${draftId}`)
expect((await invalidated.json()).data.validated).toBe(false)
// Private draft and content are also refused by the real API without this session.
const stranger = await page.context().browser()!.newContext({ baseURL: testInfo.project.use.baseURL })
try {
expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}`)).status()).toBe(401)
expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=SKILL.md`)).status()).toBe(401)
} finally { await stranger.close() }
for (const [theme, width, density] of [['light', 1440, 'regular'], ['dark', 390, 'compact']] as const) {
await page.setViewportSize({ width, height: 900 })
await page.evaluate(({ theme, density }) => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.dataset.density = density
}, { theme, density })
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath(`structure-${theme}-${width}.png`), fullPage: true })
}
} finally {
if (draftId != null) {
const removed = await page.request.delete(`/api/web/authoring/drafts/${draftId}`, { headers: await csrfHeaders(page) })
expect(removed.ok()).toBeTruthy()
}
await builder.cleanup()
}
})

View file

@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { authoringApi } from './client'
import { ApiError } from '@/shared/lib/api-error'
const fetchMock = vi.fn()
beforeEach(() => { vi.stubGlobal('fetch', fetchMock); fetchMock.mockReset(); document.cookie = 'XSRF-TOKEN=fixture-csrf; path=/' })
afterEach(() => { document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'; vi.unstubAllGlobals() })
describe('authoring request boundary', () => {
it('keeps binary encoding, revision and CSRF on file writes', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { created: true } })))
await authoringApi.saveFile(3, { path: 'assets/raw.bin', content: '/wAB', base64: true, expectedRevision: 7 })
const [url, request] = fetchMock.mock.calls[0]
expect(url).toBe('/api/web/authoring/drafts/3/files')
expect(request.method).toBe('PUT')
expect(new Headers(request.headers).get('X-XSRF-TOKEN')).toBe('fixture-csrf')
expect(JSON.parse(request.body)).toEqual({ path: 'assets/raw.bin', content: '/wAB', encoding: 'base64', expectedRevision: 7 })
})
it('encodes file paths without losing separators, unicode or query punctuation', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { content: 'hello', encoding: 'text' } })))
await authoringApi.readFile(3, 'references/示例 & notes.md')
const url = new URL(fetchMock.mock.calls[0][0], 'http://localhost')
expect(url.searchParams.get('path')).toBe('references/示例 & notes.md')
expect([...url.searchParams.keys()]).toEqual(['path'])
})
it('preserves server conflict errors instead of treating saves as successful', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 409, msg: 'Revision conflict', data: null }), { status: 409 }))
await expect(authoringApi.saveFile(3, { path: 'SKILL.md', content: 'edit', expectedRevision: 1 })).rejects.toMatchObject({ status: 409, message: 'Revision conflict' } satisfies Partial<ApiError>)
})
})

View file

@ -62,6 +62,12 @@ import type {
BatchMemberResponse,
SkillSuite,
SkillSuiteDraftInput,
AuthoringDraft,
DraftFileSummary,
DraftFileContent,
SaveDraftFileOutcome,
ValidationRunInfo,
ValidationFindingInfo,
} from './types'
import { ApiError } from '@/shared/lib/api-error'
import i18n from '@/i18n/config'
@ -2282,3 +2288,101 @@ export const notificationApi = {
})
},
}
export const authoringApi = {
async listDrafts(): Promise<AuthoringDraft[]> {
return fetchJson<AuthoringDraft[]>(`${WEB_API_PREFIX}/authoring/drafts`)
},
async createDraft(request: components['schemas']['CreateDraftRequest']): Promise<AuthoringDraft> {
return fetchJson<AuthoringDraft>(`${WEB_API_PREFIX}/authoring/drafts`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async getDraft(draftId: number): Promise<AuthoringDraft> {
return fetchJson<AuthoringDraft>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`)
},
async deleteDraft(draftId: number): Promise<void> {
await fetchJson<null>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async listFiles(draftId: number): Promise<DraftFileSummary[]> {
return fetchJson<DraftFileSummary[]>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`)
},
async readFile(draftId: number, path: string): Promise<DraftFileContent> {
const query = new URLSearchParams({ path })
return fetchJson<DraftFileContent>(
`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`,
)
},
async saveFile(
draftId: number,
request: Omit<components['schemas']['SaveDraftFileRequest'], 'encoding'> & { base64?: boolean },
): Promise<SaveDraftFileOutcome> {
return fetchJson<SaveDraftFileOutcome>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
path: request.path,
content: request.content,
encoding: request.base64 ? 'base64' : undefined,
contentType: request.contentType,
expectedRevision: request.expectedRevision,
}),
})
},
async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise<void> {
const query = new URLSearchParams({ path })
if (expectedRevision !== undefined) {
query.set('expectedRevision', String(expectedRevision))
}
await fetchJson<null>(
`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`,
{
method: 'DELETE',
headers: await ensureCsrfHeaders(),
},
)
},
async startRun(draftId: number): Promise<ValidationRunInfo> {
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async listRuns(draftId: number): Promise<ValidationRunInfo[]> {
return fetchJson<ValidationRunInfo[]>(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`)
},
async getRun(runId: number): Promise<ValidationRunInfo> {
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/runs/${runId}`)
},
async cancelRun(runId: number): Promise<ValidationRunInfo> {
return fetchJson<ValidationRunInfo>(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async listFindings(runId: number): Promise<ValidationFindingInfo[]> {
return fetchJson<ValidationFindingInfo[]>(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`)
},
}

View file

@ -885,3 +885,11 @@ export type NamespaceInvitationHistory = Required<components['schemas']['Invitat
export type NamespaceInvitationHistoryPage = Omit<Required<components['schemas']['InvitationHistoryPage']>, 'items'> & {
items: NamespaceInvitationHistory[]
}
// Existing authoring API contracts; no backend/schema changes are needed for the workbench.
export type AuthoringDraft = components['schemas']['DraftResponse']
export type DraftFileSummary = components['schemas']['DraftFileResponse']
export type DraftFileContent = components['schemas']['DraftFileContentResponse']
export type SaveDraftFileOutcome = components['schemas']['SaveDraftFileResponse']
export type ValidationRunInfo = components['schemas']['ValidationRunResponse']
export type ValidationFindingInfo = components['schemas']['ValidationFindingResponse']

View file

@ -195,6 +195,12 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions)
const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions)
const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions)
const AuthoringDraftDetailPage = createLazyRouteComponent(
() => import('@/pages/authoring/draft-detail'),
'DraftDetailPage',
dashboardRouteOptions,
)
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
@ -688,6 +694,20 @@ const dashboardTokensRoute = createRoute({
component: TokensPage,
})
const authoringDraftsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/authoring',
beforeLoad: requireAuth,
component: AuthoringDraftsPage,
})
const authoringDraftDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/authoring/$draftId',
beforeLoad: requireAuth,
component: AuthoringDraftDetailPage,
})
const cliAuthRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'cli/auth',
@ -818,6 +838,8 @@ const routeTree = rootRoute.addChildren([
dashboardSubscriptionsRoute,
dashboardNotificationsRoute,
dashboardTokensRoute,
authoringDraftsRoute,
authoringDraftDetailRoute,
cliAuthRoute,
deviceAuthRoute,
settingsSecurityRoute,

View file

@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { formatBytes } from './binary-file'
describe('formatBytes', () => {
it('formats byte sizes for humans', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2.0 KB')
expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB')
})
})

View file

@ -0,0 +1,35 @@
export function formatBytes(size: number): string {
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('failed to read file'))
reader.onload = () => {
const buffer = reader.result
if (!(buffer instanceof ArrayBuffer)) {
reject(new Error('failed to read file'))
return
}
resolve(bytesToBase64(new Uint8Array(buffer)))
}
reader.readAsArrayBuffer(file)
})
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
const chunkSize = 0x8000
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
}
return btoa(binary)
}

View file

@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { authoringApi } from '@/api/client'
import { DraftFileEditor } from './draft-file-editor'
import i18n from '@/i18n/config'
vi.mock('@tanstack/react-router', () => ({ useBlocker: () => ({ status: 'idle' }) }))
vi.mock('@/api/client', () => ({ authoringApi: { getDraft: vi.fn(), listFiles: vi.fn(), readFile: vi.fn(), saveFile: vi.fn(), deleteFile: vi.fn() } }))
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const clients: QueryClient[] = []
function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
clients.push(client)
const props = { draftId: 42, revision: 3, files: [{ path: 'SKILL.md', sha256: 'old', size: 4 }, { path: 'scripts/test.sh', sha256: 'script', size: 2 }], onSaved: vi.fn(), onDirtyChange: vi.fn() }
const view = render(<QueryClientProvider client={client}><DraftFileEditor {...props} /></QueryClientProvider>)
return { ...view, props, client }
}
beforeEach(async () => {
vi.clearAllMocks()
await i18n.changeLanguage('en')
vi.mocked(authoringApi.getDraft).mockResolvedValue({ revision: 3 })
vi.mocked(authoringApi.readFile).mockImplementation(async (_id, path) => ({ path, content: path === 'SKILL.md' ? 'original' : 'echo ok', encoding: 'text', sha256: path }))
vi.mocked(authoringApi.saveFile).mockResolvedValue({ created: true, draft: { revision: 4 } })
})
afterEach(() => { cleanup(); clients.forEach(client => client.clear()); clients.length = 0 })
describe('draft files', () => {
it('keeps edits after a conflict and uses the revision captured with the read', async () => {
const { props, rerender, client } = mount()
const editor = await screen.findByLabelText('File content')
fireEvent.change(editor, { target: { value: 'my edit' } })
rerender(<QueryClientProvider client={client}><DraftFileEditor {...props} revision={9} /></QueryClientProvider>)
vi.mocked(authoringApi.saveFile).mockRejectedValueOnce(new Error('Revision conflict'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(authoringApi.saveFile).toHaveBeenCalledWith(42, { path: 'SKILL.md', content: 'my edit', expectedRevision: 3 }))
await waitFor(() => expect((editor as HTMLTextAreaElement).disabled).toBe(false))
expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit')
expect(screen.getByText('Unsaved changes')).toBeTruthy()
})
it('requires confirmation before switching away from a dirty file', async () => {
mount()
fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } })
fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' }))
expect(screen.getByRole('dialog')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit')
fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('echo ok'))
})
it('does not create an empty file over an existing path', async () => {
mount()
await screen.findByLabelText('File content')
fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'skill.md' } })
fireEvent.click(screen.getByRole('button', { name: 'Create file' }))
expect(authoringApi.saveFile).not.toHaveBeenCalled()
})
it('refuses to overwrite a file absent from the cached list', async () => {
vi.mocked(authoringApi.listFiles).mockResolvedValue([{ path: 'new.md' }])
mount()
await screen.findByLabelText('File content')
fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'new.md' } })
fireEvent.click(screen.getByRole('button', { name: 'Create file' }))
await waitFor(() => expect(authoringApi.listFiles).toHaveBeenCalledWith(42))
await waitFor(() => expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create file' }).disabled).toBe(false))
expect(authoringApi.saveFile).not.toHaveBeenCalled()
})
it('uses server encoding rather than an extension to protect binary bytes', async () => {
vi.mocked(authoringApi.readFile).mockResolvedValue({ content: '/wAB', encoding: 'base64', sha256: 'binary' })
mount()
await screen.findByTestId('binary-file-panel')
expect(screen.queryByLabelText('File content')).toBeNull()
expect(screen.queryByText('/wAB')).toBeNull()
})
it('shows a retry on read failure instead of an editable empty file', async () => {
vi.mocked(authoringApi.readFile).mockRejectedValueOnce(new Error('unavailable'))
mount()
await screen.findByRole('alert')
expect(screen.queryByLabelText('File content')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await screen.findByLabelText('File content')
})
it('can discard edits and reload even when the server bytes did not change', async () => {
mount()
fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } })
fireEvent.click(screen.getByRole('button', { name: 'Reload file' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('original'))
expect(screen.queryByText('Unsaved changes')).toBeNull()
})
})

View file

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { useBlocker } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { authoringApi } from '@/api/client'
import type { DraftFileSummary } from '@/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Textarea } from '@/shared/ui/textarea'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { toast } from '@/shared/lib/toast'
import { fileToBase64, formatBytes } from './binary-file'
import { QueryError } from './query-error'
export function DraftFileEditor({ draftId, files, revision, onSaved, onDirtyChange }: {
draftId: number, files: DraftFileSummary[], revision: number,
onSaved: () => void, onDirtyChange: (dirty: boolean) => void,
}) {
const { t } = useTranslation()
const client = useQueryClient()
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [newPath, setNewPath] = useState('')
const [uploadFile, setUploadFile] = useState<File | null>(null)
const [busy, setBusy] = useState(false)
const [dirty, setDirty] = useState(false)
const [discardPath, setDiscardPath] = useState<string | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [reloadOpen, setReloadOpen] = useState(false)
const [editorGeneration, setEditorGeneration] = useState(0)
const paths = files.flatMap(file => file.path ? [file.path] : []).sort()
const path = selectedPath && paths.includes(selectedPath) ? selectedPath : paths[0] ?? null
const selected = files.find(file => file.path === path)
const readKey = ['authoring', 'draft', draftId, 'content', path]
const read = useQuery({
queryKey: readKey,
queryFn: async () => {
if (!path) throw new Error(t('authoring.files.empty'))
// Capture the revision at the start of this read; later refreshes must not
// silently authorize an edit based on stale bytes against a newer revision.
const before = await authoringApi.getDraft(draftId)
if (before.revision == null) throw new Error(t('authoring.ui.loadFailed'))
const file = await authoringApi.readFile(draftId, path)
return { ...file, revision: before.revision }
},
enabled: path != null, meta: { skipGlobalErrorHandler: true }, retry: false,
staleTime: Infinity,
})
const loadedFile = read.data
const blocker = useBlocker({ shouldBlockFn: () => dirty || busy, enableBeforeUnload: dirty || busy, withResolver: true })
useEffect(() => { onDirtyChange(dirty || busy) }, [dirty, busy, onDirtyChange])
const choosePath = (next: string) => {
if (next === path) return
if (dirty) setDiscardPath(next)
else setSelectedPath(next)
}
const mutateFile = async (operation: () => Promise<unknown>, successKey: string, nextPath?: string) => {
if (busy) return
setBusy(true)
try {
await operation()
setDirty(false)
if (nextPath) setSelectedPath(nextPath)
await client.invalidateQueries({ queryKey: ['authoring', 'draft', draftId, 'content'] })
onSaved()
toast.success(t(successKey))
return true
} catch (error) {
toast.error(error instanceof Error ? error.message : t('apiError.unknown'))
return false
} finally { setBusy(false) }
}
const create = async () => {
const next = newPath.trim().replace(/\\/g, '/').replace(/(^|\/)skill\.md$/i, '$1SKILL.md')
if (!next || busy || dirty) return
// Creating a file must never overwrite an existing resource with empty bytes.
if (paths.includes(next)) {
toast.error(t('authoring.files.exists'))
return
}
const created = await mutateFile(async () => {
// Read the revision before listing files. Any concurrent creation after
// that revision either appears in the list or makes the write conflict.
const snapshot = await authoringApi.getDraft(draftId)
if (snapshot.revision == null) throw new Error(t('authoring.ui.loadFailed'))
const latestFiles = await authoringApi.listFiles(draftId)
if (latestFiles.some(file => file.path === next)) {
throw new Error(t('authoring.files.exists'))
}
return authoringApi.saveFile(draftId, {
path: next, content: '', expectedRevision: snapshot.revision,
})
}, 'authoring.files.created', next)
if (created) setNewPath('')
}
const upload = async () => {
if (!uploadFile || !newPath.trim() || busy || dirty) return
const file = uploadFile
const next = newPath.trim()
const ok = await mutateFile(async () => authoringApi.saveFile(draftId, {
path: next, content: await fileToBase64(file), base64: true,
contentType: file.type || undefined, expectedRevision: revision,
}), 'authoring.files.uploaded', next)
if (ok) { setUploadFile(null); setNewPath('') }
}
return (
<div className="grid min-w-0 gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="min-w-0 space-y-3">
<ul className="space-y-1" aria-label={t('authoring.files.title')}>
{paths.map(name => <li key={name}><button type="button" disabled={busy} aria-pressed={path === name} title={name} onClick={() => choosePath(name)} className={`w-full break-all rounded-md px-2 py-2 text-left font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${path === name ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>{name}</button></li>)}
</ul>
<Label htmlFor="new-file-path">{t('authoring.files.path')}</Label>
<Input id="new-file-path" value={newPath} disabled={busy || dirty} onChange={event => setNewPath(event.target.value)} placeholder="scripts/run.py" />
<Button size="sm" variant="outline" onClick={() => void create()} disabled={busy || dirty || !newPath.trim()}>{t('authoring.files.create')}</Button>
<Label htmlFor="upload-file">{t('authoring.files.upload')}</Label>
<Input id="upload-file" type="file" disabled={busy || dirty} onChange={event => {
const file = event.target.files?.[0] ?? null
setUploadFile(file)
if (file && !newPath.trim()) setNewPath(`assets/${file.name}`)
}} />
<p className="text-xs text-muted-foreground">{t('authoring.files.replaceHint')}</p>
<Button size="sm" variant="outline" onClick={() => void upload()} disabled={busy || dirty || !uploadFile || !newPath.trim()}>{t(busy ? 'authoring.files.uploading' : 'authoring.files.uploadConfirm')}</Button>
</div>
<div className="min-w-0 space-y-3">
{!path && <p>{t('authoring.files.empty')}</p>}
{path && read.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
{path && read.isError && <QueryError error={read.error} retry={() => void read.refetch()} />}
{path && loadedFile && (loadedFile.encoding === 'text' ? <TextFileEditor key={`${draftId}:${path}:${loadedFile.sha256}:${editorGeneration}`} content={loadedFile.content ?? ''} disabled={busy} onDirtyChange={setDirty} onSave={content => mutateFile(() => authoringApi.saveFile(draftId, { path, content, expectedRevision: loadedFile.revision }), 'authoring.files.saved')} /> : <div className="space-y-2 rounded-md border p-4" data-testid="binary-file-panel">
<p className="break-all font-mono text-sm">{path}</p>
<p className="text-sm">{t('authoring.files.size')}: {formatBytes(selected?.size ?? 0)}</p>
<p className="break-all font-mono text-xs">sha256: {selected?.sha256}</p>
<p className="text-sm text-muted-foreground">{t('authoring.files.binaryHint')}</p>
</div>)}
{path && <Button variant="outline" size="sm" disabled={busy} onClick={() => dirty ? setReloadOpen(true) : void read.refetch()}>{t('authoring.files.reload')}</Button>}
{path && <Button variant="outline" size="sm" disabled={busy} onClick={() => setDeleteOpen(true)}>{t('authoring.files.delete')}</Button>}
</div>
<ConfirmDialog open={discardPath != null} onOpenChange={open => { if (!open) setDiscardPath(null) }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { setDirty(false); setSelectedPath(discardPath); setDiscardPath(null) }} />
<ConfirmDialog open={blocker.status === 'blocked'} onOpenChange={open => { if (!open) blocker.reset?.() }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { if (!busy) blocker.proceed?.() }} closeOnConfirm={false} />
<ConfirmDialog open={reloadOpen} onOpenChange={setReloadOpen} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={async () => { const result = await read.refetch(); if (result.isSuccess) { setEditorGeneration(value => value + 1); setDirty(false); setReloadOpen(false) } }} />
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={t('authoring.files.deleteTitle')} description={t('authoring.files.deleteDescription', { path })} variant="destructive" closeOnConfirm={false} onConfirm={async () => {
if (!path) return
if (await mutateFile(() => authoringApi.deleteFile(draftId, path, read.data?.revision ?? revision), 'authoring.files.deleted')) { setDeleteOpen(false); setSelectedPath(null) }
}} />
</div>
)
}
function TextFileEditor({ content: initial, disabled, onDirtyChange, onSave }: {
content: string, disabled: boolean, onDirtyChange: (dirty: boolean) => void,
onSave: (content: string) => Promise<boolean | undefined>,
}) {
const { t } = useTranslation()
const [content, setContent] = useState(initial)
const [saved, setSaved] = useState(initial)
const dirty = content !== saved
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false) }, [dirty, onDirtyChange])
return <div className="space-y-2">
<Label htmlFor="draft-file-content">{t('authoring.files.content')}</Label>
<Textarea id="draft-file-content" value={content} disabled={disabled} onChange={event => setContent(event.target.value)} rows={18} spellCheck={false} className="font-mono text-sm" />
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={disabled || !dirty} onClick={async () => { if (await onSave(content)) setSaved(content) }}>{t('authoring.files.save')}</Button>
{dirty && <span role="status" className="text-sm text-muted-foreground">{t('authoring.files.unsaved')}</span>}
</div>
</div>
}

View file

@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
import { ApiError } from '@/shared/lib/api-error'
import { Button } from '@/shared/ui/button'
export function QueryError({ error, retry }: { error: unknown, retry: () => void }) {
const { t } = useTranslation()
const forbidden = error instanceof ApiError && (error.status === 401 || error.status === 403)
return (
<div role="alert" className="space-y-2 text-sm text-destructive">
<p>{t(forbidden ? 'apiError.forbidden' : 'authoring.ui.loadFailed')}</p>
<Button variant="outline" size="sm" onClick={retry}>{t('authoring.ui.retry')}</Button>
</div>
)
}

View file

@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { authoringApi } from '@/api/client'
import i18n from '@/i18n/config'
import { ValidationResults } from './validation-results'
vi.mock('@/api/client', () => ({ authoringApi: { getRun: vi.fn(), listFindings: vi.fn(), cancelRun: vi.fn() } }))
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const clients: QueryClient[] = []
function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
clients.push(client)
render(<QueryClientProvider client={client}><ValidationResults draftId={42} runId={8} /></QueryClientProvider>)
return client
}
beforeEach(async () => { vi.clearAllMocks(); await i18n.changeLanguage('en') })
afterEach(() => { cleanup(); clients.forEach(client => client.clear()); clients.length = 0 })
describe('structure validation results', () => {
it('loads final findings and refreshes draft validity on completion', async () => {
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 42, status: 'RUNNING', active: true, terminal: false })
vi.mocked(authoringApi.listFindings).mockResolvedValueOnce([]).mockResolvedValue([{ id: 1, severity: 'ERROR', ruleCode: 'FRONTMATTER_FIELD_MISSING', filePath: 'SKILL.md', location: 'frontmatter', message: 'Missing description' }])
const client = mount()
client.setQueryData(['authoring', 'draft', 42], { validated: false })
await screen.findByText('Validation is in progress. Findings will appear here.')
client.setQueryData(['authoring', 'run', 8], { id: 8, draftId: 42, status: 'FAILED', terminal: true, active: false, errorCount: 1 })
await screen.findByText('Missing description')
expect(screen.getByText('SKILL.md · frontmatter')).toBeTruthy()
expect(client.getQueryState(['authoring', 'draft', 42])?.isInvalidated).toBe(true)
expect(screen.queryByRole('button', { name: 'Cancel run' })).toBeNull()
})
it('cancels an active run and shows its terminal state', async () => {
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 42, status: 'RUNNING', active: true })
vi.mocked(authoringApi.listFindings).mockResolvedValue([])
vi.mocked(authoringApi.cancelRun).mockResolvedValue({ id: 8, draftId: 42, status: 'CANCELLED', terminal: true, active: false })
mount()
fireEvent.click(await screen.findByRole('button', { name: 'Cancel run' }))
await waitFor(() => expect(authoringApi.cancelRun).toHaveBeenCalledWith(8))
await screen.findByText(/Cancelled/)
expect(screen.queryByText(/passed validation/)).toBeNull()
})
it('does not fetch findings for a run belonging to another draft', async () => {
vi.mocked(authoringApi.getRun).mockResolvedValue({ id: 8, draftId: 99, status: 'SUCCEEDED', terminal: true })
mount()
await screen.findByRole('alert')
expect(authoringApi.listFindings).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,62 @@
import { useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { authoringApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { toast } from '@/shared/lib/toast'
import { QueryError } from './query-error'
export function ValidationResults({ runId, draftId }: { runId: number, draftId: number }) {
const { t } = useTranslation()
const client = useQueryClient()
const runKey = ['authoring', 'run', runId]
const run = useQuery({
queryKey: runKey, queryFn: () => authoringApi.getRun(runId),
refetchInterval: query => query.state.data?.terminal ? false : 1500,
meta: { skipGlobalErrorHandler: true }, retry: false,
})
const findings = useQuery({
queryKey: [...runKey, 'findings', run.data?.status], queryFn: () => authoringApi.listFindings(runId),
enabled: run.isSuccess && run.data.draftId === draftId,
refetchInterval: run.data?.terminal ? false : 1500,
meta: { skipGlobalErrorHandler: true }, retry: false,
})
useEffect(() => {
if (run.data?.terminal && run.data.draftId === draftId) {
void client.invalidateQueries({ queryKey: ['authoring', 'draft', draftId] })
void client.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
}
}, [client, draftId, run.data?.terminal, run.data?.draftId])
const cancel = useMutation({
mutationFn: () => authoringApi.cancelRun(runId),
onSuccess: data => client.setQueryData(runKey, data),
onError: error => toast.error(error instanceof Error ? error.message : t('apiError.unknown')),
})
if (run.isPending) return <p role="status">{t('authoring.ui.loading')}</p>
if (run.isError) return <QueryError error={run.error} retry={() => void run.refetch()} />
if (run.data.draftId !== draftId) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
const data = run.data
return (
<Card><CardContent className="space-y-4 pt-6" data-testid="validation-results">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1" role="status">
<p className="font-medium">{t(`authoring.run.status.${data.status}`, { defaultValue: data.status })} · {t('authoring.run.revision')} {data.draftRevision}</p>
<p className="text-sm text-muted-foreground">{t('authoring.run.errors', { count: data.errorCount ?? 0 })} · {t('authoring.run.warnings', { count: data.warningCount ?? 0 })}</p>
</div>
{data.active && <Button variant="outline" disabled={cancel.isPending || data.cancelRequested} onClick={() => cancel.mutate()}>{t('authoring.run.cancel')}</Button>}
</div>
{findings.isPending && <p>{t('authoring.ui.loading')}</p>}
{findings.isError && <QueryError error={findings.error} retry={() => void findings.refetch()} />}
{findings.isSuccess && findings.data.length === 0 && <p className="text-sm text-muted-foreground">{t(data.terminal ? 'authoring.run.noFindings' : 'authoring.run.waiting')}</p>}
<ul className="space-y-3">
{findings.data?.map((finding, index) => <li key={finding.id ?? index} className="space-y-1 rounded-md border p-3">
<p className="break-words text-sm font-medium">{finding.severity === 'ERROR' ? t('authoring.run.error') : t('authoring.run.warning')} · {finding.ruleCode}</p>
<p className="break-words text-sm">{finding.message}</p>
{finding.filePath && <p className="break-all font-mono text-xs text-muted-foreground">{finding.filePath}{finding.location ? ` · ${finding.location}` : ''}</p>}
{finding.suggestion?.description && <p className="break-words text-sm text-muted-foreground">{finding.suggestion.description}</p>}
</li>)}
</ul>
</CardContent></Card>
)
}

View file

@ -125,3 +125,25 @@ describe('clearSessionScopedQueries', () => {
}
})
})
describe('authoring session isolation', () => {
it('drops private draft caches and ignores a late response after the viewer changes', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
let finishPrevious: (value: string[]) => void = () => {}
const previous = new Promise<string[]>(resolve => { finishPrevious = resolve })
const loader = vi.fn().mockReturnValueOnce(previous).mockResolvedValue(['viewer-b-draft'])
client.setQueryData(['authoring', 'draft', 42, 'content', 'SKILL.md'], { content: 'viewer-a-private' })
const observer = new QueryObserver(client, { queryKey: ['authoring', 'drafts'], queryFn: loader })
const unsubscribe = observer.subscribe(() => {})
try {
clearSessionScopedQueries(client)
expect(client.getQueryData(['authoring', 'draft', 42, 'content', 'SKILL.md'])).toBeUndefined()
await vi.waitFor(() => expect(observer.getCurrentResult().data).toEqual(['viewer-b-draft']))
finishPrevious(['viewer-a-draft'])
await previous
await Promise.resolve()
expect(observer.getCurrentResult().data).toEqual(['viewer-b-draft'])
} finally { unsubscribe(); client.clear() }
})
})

View file

@ -5,6 +5,7 @@ export function getNotificationQueryKeyScope(userId?: string | null) {
}
export function clearSessionScopedQueries(queryClient: QueryClient) {
void queryClient.resetQueries({ queryKey: ['authoring'] })
queryClient.removeQueries({ queryKey: ['notifications'] })
queryClient.removeQueries({ queryKey: ['labels'] })
queryClient.removeQueries({ queryKey: ['skills', 'my'] })

View file

@ -496,6 +496,7 @@
"userId": "User ID"
},
"sidebar": {
"authoring": "Skill authoring",
"account": "Account",
"profile": "Profile",
"security": "Security",
@ -3299,5 +3300,95 @@
"title": "Subscribed skill version yanked",
"description": "{{skillName}}{{versionSuffix}} version was yanked."
}
},
"authoring": {
"ui": {
"loading": "Loading…",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"submitting": "Submitting…",
"loadFailed": "Unable to load this resource.",
"retry": "Retry"
},
"list": {
"title": "Skill authoring",
"subtitle": "Create and edit private drafts, then check their package structure.",
"create": "New draft",
"empty": "No drafts yet. Create one to start authoring a skill.",
"colName": "Name",
"colRevision": "Revision",
"colValidated": "Validation",
"colUpdated": "Updated",
"colActions": "Actions",
"validated": "Structure checked",
"notValidated": "Not validated",
"deleteTitle": "Delete draft",
"deleteDescription": "Delete draft “{{name}}” and all of its files? This cannot be undone.",
"created": "Draft created",
"deleted": "Draft deleted",
"createTitle": "Create a skill draft",
"createDescription": "Starts from a SKILL.md scaffold you can edit and validate.",
"fieldNamespace": "Namespace",
"namespacePlaceholder": "Select a namespace",
"fieldName": "Skill name",
"fieldRequirement": "Requirement (optional)",
"requirementPlaceholder": "What should this skill do?"
},
"detail": {
"back": "All drafts",
"validate": "Check structure",
"starting": "Starting…",
"tabRuns": "Validation runs",
"noRuns": "No validation runs yet. Start one to check the draft.",
"loadFailed": "Failed to load the draft.",
"structureOnly": "This checks the skill package structure. It does not execute scripts, assess behavior or publish the skill.",
"saveFirst": "Save or discard file changes before starting validation."
},
"files": {
"save": "Save",
"saved": "File saved",
"create": "Create file",
"created": "File created",
"exists": "A file already exists at this path. Choose another path.",
"delete": "Delete",
"deleted": "File deleted",
"empty": "No files yet.",
"unsaved": "Unsaved changes",
"upload": "Upload file",
"uploadConfirm": "Upload",
"uploading": "Uploading…",
"uploaded": "File uploaded",
"path": "Path",
"size": "Size",
"binaryHint": "This is a binary file and cannot be edited as text. Upload a new file to replace it.",
"replaceHint": "Uploading to an existing path replaces that resource.",
"discardTitle": "Discard unsaved changes?",
"discardDescription": "Your unsaved edits will be lost.",
"deleteTitle": "Delete file?",
"deleteDescription": "Delete {{path}} from this draft? Unsaved edits to this file will be lost.",
"content": "File content",
"title": "Draft files",
"reload": "Reload file"
},
"run": {
"revision": "revision",
"errors": "{{count}} error(s)",
"warnings": "{{count}} warning(s)",
"cancel": "Cancel run",
"noFindings": "No findings recorded for this run.",
"waiting": "Validation is in progress. Findings will appear here.",
"error": "Error",
"warning": "Warning",
"status": {
"QUEUED": "Queued",
"PREPARING": "Preparing",
"RUNNING": "Running",
"SUCCEEDED": "Succeeded",
"FAILED": "Failed",
"CANCELLED": "Cancelled",
"TIMED_OUT": "Timed out"
}
}
}
}

View file

@ -533,6 +533,7 @@
"userId": "사용자 ID"
},
"sidebar": {
"authoring": "스킬 작성",
"account": "계정",
"profile": "프로필",
"security": "보안",
@ -3580,5 +3581,95 @@
"title": "구독한 스킬 버전 배포 철회",
"description": "{{skillName}}{{versionSuffix}} 버전의 배포가 철회되었습니다."
}
},
"authoring": {
"ui": {
"loading": "불러오는 중…",
"cancel": "취소",
"delete": "삭제",
"create": "만들기",
"submitting": "처리 중…",
"loadFailed": "데이터를 불러올 수 없습니다.",
"retry": "다시 시도"
},
"list": {
"title": "스킬 작성",
"subtitle": "비공개 초안을 만들고 편집한 후 패키지 구조를 검사합니다.",
"create": "새 초안",
"empty": "아직 초안이 없습니다.",
"colName": "이름",
"colRevision": "수정 버전",
"colValidated": "검증",
"colUpdated": "수정 시각",
"colActions": "작업",
"validated": "구조 검증 완료",
"notValidated": "검증되지 않음",
"deleteTitle": "초안 삭제",
"deleteDescription": "초안 “{{name}}”과 모든 파일을 삭제하시겠습니까? 되돌릴 수 없습니다.",
"created": "초안을 만들었습니다.",
"deleted": "초안을 삭제했습니다.",
"createTitle": "스킬 초안 만들기",
"createDescription": "편집하고 검증할 수 있는 SKILL.md 템플릿으로 시작합니다.",
"fieldNamespace": "네임스페이스",
"namespacePlaceholder": "네임스페이스 선택",
"fieldName": "스킬 이름",
"fieldRequirement": "요구 사항 (선택 사항)",
"requirementPlaceholder": "이 스킬은 어떤 작업을 해야 하나요?"
},
"detail": {
"back": "모든 초안",
"validate": "구조 검사",
"starting": "시작 중…",
"tabRuns": "검증 실행 기록",
"noRuns": "아직 검증 기록이 없습니다.",
"loadFailed": "초안을 불러올 수 없습니다.",
"structureOnly": "패키지 구조만 검사합니다. 스크립트를 실행하거나 동작을 평가하거나 스킬을 게시하지 않습니다.",
"saveFirst": "검증 전에 변경 사항을 저장하거나 취소하세요."
},
"files": {
"save": "저장",
"saved": "파일을 저장했습니다.",
"create": "파일 만들기",
"created": "파일을 만들었습니다.",
"exists": "같은 경로에 파일이 있습니다. 다른 경로를 선택하세요.",
"delete": "삭제",
"deleted": "파일을 삭제했습니다.",
"empty": "파일이 없습니다.",
"unsaved": "저장되지 않은 변경 사항",
"upload": "파일 업로드",
"uploadConfirm": "업로드",
"uploading": "업로드 중…",
"uploaded": "파일을 업로드했습니다.",
"path": "경로",
"size": "크기",
"binaryHint": "바이너리 파일은 텍스트로 편집할 수 없습니다. 새 파일을 업로드하여 교체하세요.",
"replaceHint": "기존 경로에 업로드하면 해당 파일이 교체됩니다.",
"discardTitle": "저장하지 않은 변경 사항을 취소할까요?",
"discardDescription": "저장하지 않은 변경 사항이 사라집니다.",
"deleteTitle": "파일을 삭제할까요?",
"deleteDescription": "초안에서 {{path}} 파일을 삭제할까요? 저장하지 않은 변경 사항도 사라집니다.",
"content": "파일 내용",
"title": "초안 파일",
"reload": "파일 다시 불러오기"
},
"run": {
"revision": "수정 버전",
"errors": "오류 {{count}}개",
"warnings": "경고 {{count}}개",
"cancel": "실행 취소",
"noFindings": "기록된 문제가 없습니다.",
"waiting": "검증 중입니다. 결과가 여기에 표시됩니다.",
"error": "오류",
"warning": "경고",
"status": {
"QUEUED": "대기 중",
"PREPARING": "준비 중",
"RUNNING": "실행 중",
"SUCCEEDED": "성공",
"FAILED": "실패",
"CANCELLED": "취소됨",
"TIMED_OUT": "시간 초과"
}
}
}
}

View file

@ -496,6 +496,7 @@
"userId": "ID пользователя"
},
"sidebar": {
"authoring": "Создание навыков",
"account": "Аккаунт",
"profile": "Профиль",
"security": "Безопасность",
@ -3301,5 +3302,95 @@
"title": "Версия подписанного навыка отозвана",
"description": "Версия {{skillName}}{{versionSuffix}} отозвана."
}
},
"authoring": {
"ui": {
"loading": "Загрузка…",
"cancel": "Отмена",
"delete": "Удалить",
"create": "Создать",
"submitting": "Отправка…",
"loadFailed": "Не удалось загрузить данные.",
"retry": "Повторить"
},
"list": {
"title": "Создание Skill",
"subtitle": "Создавайте частные черновики и проверяйте структуру пакетов.",
"create": "Новый черновик",
"empty": "Черновиков пока нет. Создайте первый.",
"colName": "Название",
"colRevision": "Ревизия",
"colValidated": "Валидация",
"colUpdated": "Обновлён",
"colActions": "Действия",
"validated": "Структура проверена",
"notValidated": "Не проверен",
"deleteTitle": "Удалить черновик",
"deleteDescription": "Удалить черновик «{{name}}» и все его файлы? Действие необратимо.",
"created": "Черновик создан",
"deleted": "Черновик удалён",
"createTitle": "Создать черновик Skill",
"createDescription": "Начинается со скаффолда SKILL.md, который можно редактировать и валидировать.",
"fieldNamespace": "Пространство имён",
"namespacePlaceholder": "Выберите пространство имён",
"fieldName": "Имя Skill",
"fieldRequirement": "Требование (необязательно)",
"requirementPlaceholder": "Что должен делать этот Skill?"
},
"detail": {
"back": "Все черновики",
"validate": "Проверить структуру",
"starting": "Запуск…",
"tabRuns": "Прогоны валидации",
"noRuns": "Прогонов ещё нет. Запустите валидацию.",
"loadFailed": "Не удалось загрузить черновик.",
"structureOnly": "Проверяется только структура пакета. Скрипты не запускаются, поведение не оценивается, навык не публикуется.",
"saveFirst": "Сохраните или отмените изменения перед проверкой."
},
"files": {
"save": "Сохранить",
"saved": "Файл сохранён",
"create": "Создать файл",
"created": "Файл создан",
"exists": "По этому пути уже есть файл. Выберите другой путь.",
"delete": "Удалить",
"deleted": "Файл удалён",
"empty": "Файлов пока нет.",
"unsaved": "Несохранённые изменения",
"upload": "Загрузить файл",
"uploadConfirm": "Загрузить",
"uploading": "Загрузка…",
"uploaded": "Файл загружен",
"path": "Путь",
"size": "Размер",
"binaryHint": "Это двоичный файл, его нельзя редактировать как текст. Загрузите новый файл для замены.",
"replaceHint": "Загрузка по существующему пути заменит файл.",
"discardTitle": "Отменить несохранённые изменения?",
"discardDescription": "Несохранённые изменения будут потеряны.",
"deleteTitle": "Удалить файл?",
"deleteDescription": "Удалить {{path}} из черновика? Несохранённые изменения файла будут потеряны.",
"content": "Содержимое файла",
"title": "Файлы черновика",
"reload": "Перезагрузить файл"
},
"run": {
"revision": "ревизия",
"errors": "ошибок: {{count}}",
"warnings": "предупреждений: {{count}}",
"cancel": "Отменить прогон",
"noFindings": "Для этого запуска замечаний нет.",
"waiting": "Проверка выполняется. Результаты появятся здесь.",
"error": "Ошибка",
"warning": "Предупреждение",
"status": {
"QUEUED": "В очереди",
"PREPARING": "Подготовка",
"RUNNING": "Выполняется",
"SUCCEEDED": "Успешно",
"FAILED": "Ошибка",
"CANCELLED": "Отменено",
"TIMED_OUT": "Время истекло"
}
}
}
}

View file

@ -496,6 +496,7 @@
"userId": "用户 ID"
},
"sidebar": {
"authoring": "技能创作",
"account": "账号管理",
"profile": "个人设置",
"security": "安全设置",
@ -3298,5 +3299,95 @@
"title": "订阅技能版本撤回",
"description": "{{skillName}}{{versionSuffix}} 版本已撤回。"
}
},
"authoring": {
"ui": {
"loading": "加载中…",
"cancel": "取消",
"delete": "删除",
"create": "创建",
"submitting": "提交中…",
"loadFailed": "加载失败,请重试。",
"retry": "重试"
},
"list": {
"title": "Skill 创作工作台",
"subtitle": "创建、编辑私有草稿,并检查技能包结构。",
"create": "新建草稿",
"empty": "还没有草稿。创建一个开始编写 Skill。",
"colName": "名称",
"colRevision": "版本",
"colValidated": "验证状态",
"colUpdated": "更新时间",
"colActions": "操作",
"validated": "结构已验证",
"notValidated": "未验证",
"deleteTitle": "删除草稿",
"deleteDescription": "删除草稿“{{name}}”及其所有文件?该操作不可撤销。",
"created": "草稿已创建",
"deleted": "草稿已删除",
"createTitle": "创建 Skill 草稿",
"createDescription": "以 SKILL.md 脚手架为起点,可编辑并验证。",
"fieldNamespace": "命名空间",
"namespacePlaceholder": "选择命名空间",
"fieldName": "Skill 名称",
"fieldRequirement": "需求(可选)",
"requirementPlaceholder": "这个 Skill 应该做什么?"
},
"detail": {
"back": "返回草稿列表",
"validate": "验证结构",
"starting": "启动中…",
"tabRuns": "验证记录",
"noRuns": "还没有验证记录。先运行一次验证。",
"loadFailed": "草稿加载失败。",
"structureOnly": "此处仅检查技能包结构,不执行脚本、不评估行为,也不发布技能。",
"saveFirst": "请先保存或放弃文件修改,再开始验证。"
},
"files": {
"save": "保存",
"saved": "文件已保存",
"create": "新建文件",
"created": "文件已创建",
"exists": "该路径已有文件,请使用其他路径。",
"delete": "删除",
"deleted": "文件已删除",
"empty": "暂无文件。",
"unsaved": "有未保存的修改",
"upload": "上传文件",
"uploadConfirm": "上传",
"uploading": "上传中…",
"uploaded": "文件已上传",
"path": "路径",
"size": "大小",
"binaryHint": "这是二进制文件,无法作为文本编辑。可上传新文件替换。",
"replaceHint": "上传到已有路径会替换对应文件。",
"discardTitle": "放弃未保存的修改?",
"discardDescription": "未保存的编辑内容将丢失。",
"deleteTitle": "删除文件?",
"deleteDescription": "确认从草稿删除 {{path}}?该文件未保存的修改也将丢失。",
"content": "文件内容",
"title": "草稿文件",
"reload": "重新加载文件"
},
"run": {
"revision": "版本",
"errors": "{{count}} 个错误",
"warnings": "{{count}} 个警告",
"cancel": "取消运行",
"noFindings": "本次运行没有记录问题。",
"waiting": "验证进行中,发现的问题将在此显示。",
"error": "错误",
"warning": "警告",
"status": {
"QUEUED": "排队中",
"PREPARING": "准备中",
"RUNNING": "运行中",
"SUCCEEDED": "通过",
"FAILED": "失败",
"CANCELLED": "已取消",
"TIMED_OUT": "已超时"
}
}
}
}

View file

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ArrowLeft, Play } from 'lucide-react'
import type { ValidationRunInfo } from '@/api/types'
import { authoringApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { toast } from '@/shared/lib/toast'
import { DraftFileEditor } from '@/features/authoring/draft-file-editor'
import { QueryError } from '@/features/authoring/query-error'
import { ValidationResults } from '@/features/authoring/validation-results'
export function DraftDetailPage() {
const { draftId } = useParams({ from: '/dashboard/authoring/$draftId' })
const id = Number(draftId)
// Resource changes reset local editor/run selection along with query identity.
return <DraftWorkbench key={draftId} id={id} />
}
function DraftWorkbench({ id }: { id: number }) {
const { t } = useTranslation()
const client = useQueryClient()
const [dirty, setDirty] = useState(false)
const [selectedRunId, setSelectedRunId] = useState<number | null>(null)
const validId = Number.isSafeInteger(id) && id > 0
const draft = useQuery({
queryKey: ['authoring', 'draft', id], queryFn: () => authoringApi.getDraft(id),
enabled: validId, meta: { skipGlobalErrorHandler: true }, retry: false,
})
const files = useQuery({
queryKey: ['authoring', 'draft', id, 'files'], queryFn: () => authoringApi.listFiles(id),
enabled: validId && draft.data != null, meta: { skipGlobalErrorHandler: true }, retry: false,
})
const runs = useQuery({
queryKey: ['authoring', 'draft', id, 'runs'], queryFn: () => authoringApi.listRuns(id),
enabled: validId && draft.data != null, meta: { skipGlobalErrorHandler: true }, retry: false,
refetchInterval: query => query.state.data?.some(run => run.active) ? 1500 : false,
})
const activeRun = runs.data?.some(run => run.active) ?? false
const refreshDraft = () => {
void client.invalidateQueries({ queryKey: ['authoring', 'draft', id] })
void client.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
}
const startRun = useMutation({
mutationFn: () => authoringApi.startRun(id),
onSuccess: run => {
setSelectedRunId(run.id ?? null)
client.setQueryData<ValidationRunInfo[]>(['authoring', 'draft', id, 'runs'], old => [run, ...(old ?? []).filter(item => item.id !== run.id)])
refreshDraft()
},
onError: error => toast.error(error instanceof Error ? error.message : t('apiError.unknown')),
})
// Keep an active run visible even when the page is refreshed or opened mid-run.
const selected = selectedRunId ?? runs.data?.find(run => run.active)?.id ?? runs.data?.[0]?.id
useEffect(() => {
if (activeRun) void client.invalidateQueries({ queryKey: ['authoring', 'draft', id] })
}, [activeRun, client, id])
if (!validId) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
if (draft.isPending) return <p role="status">{t('authoring.ui.loading')}</p>
if (!draft.data) return <QueryError error={draft.error} retry={() => void draft.refetch()} />
const current = draft.data
if (current.revision == null) return <p role="alert">{t('authoring.detail.loadFailed')}</p>
return (
<div className="min-w-0 space-y-6">
<Link to="/dashboard/authoring" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" aria-hidden />{t('authoring.detail.back')}
</Link>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<h1 className="break-all text-2xl font-semibold">{current.name}</h1>
<p className="text-sm text-muted-foreground">{t('authoring.run.revision')} {current.revision} · {t(current.validated ? 'authoring.list.validated' : 'authoring.list.notValidated')}</p>
</div>
<Button onClick={() => startRun.mutate()} disabled={startRun.isPending || activeRun || dirty || files.isPending || files.isError} data-testid="start-run">
<Play className="mr-1 h-4 w-4" aria-hidden />{t(startRun.isPending ? 'authoring.detail.starting' : 'authoring.detail.validate')}
</Button>
</div>
<p className="text-sm text-muted-foreground">{t('authoring.detail.structureOnly')}</p>
{dirty && <p role="status" className="text-sm text-muted-foreground">{t('authoring.detail.saveFirst')}</p>}
{draft.isError && <QueryError error={draft.error} retry={() => void draft.refetch()} />}
<Card><CardContent className="pt-6">
{files.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
{files.isError && <QueryError error={files.error} retry={() => void files.refetch()} />}
{files.data && <DraftFileEditor draftId={id} files={files.data} revision={current.revision} onSaved={refreshDraft} onDirtyChange={setDirty} />}
</CardContent></Card>
<section className="space-y-3" aria-label={t('authoring.detail.tabRuns')}>
<h2 className="text-lg font-semibold">{t('authoring.detail.tabRuns')}</h2>
{runs.isPending && <p role="status">{t('authoring.ui.loading')}</p>}
{runs.isError && <QueryError error={runs.error} retry={() => void runs.refetch()} />}
{runs.isSuccess && runs.data.length === 0 && <p>{t('authoring.detail.noRuns')}</p>}
<div className="flex flex-wrap gap-2">
{runs.data?.map(run => run.id != null && <Button key={run.id} variant={selected === run.id ? 'default' : 'outline'} size="sm" onClick={() => setSelectedRunId(run.id ?? null)} aria-pressed={selected === run.id}>
#{run.id} · {t(`authoring.run.status.${run.status}`, { defaultValue: run.status })}
</Button>)}
</div>
{selected != null && <ValidationResults key={selected} runId={selected} draftId={id} />}
</section>
</div>
)
}

View file

@ -0,0 +1,288 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Plus } from 'lucide-react'
import { authoringApi, namespaceApi } from '@/api/client'
import type { AuthoringDraft } from '@/api/types'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/shared/ui/table'
import { Textarea } from '@/shared/ui/textarea'
import { QueryError } from '@/features/authoring/query-error'
import { toast } from '@/shared/lib/toast'
import { formatLocalDateTime } from '@/shared/lib/date-time'
/**
* Authoring workbench entry: the user's skill drafts with validation state and
* quick access to the editor, plus draft creation.
*/
export function DraftsPage() {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AuthoringDraft | null>(null)
const drafts = useQuery({
queryKey: ['authoring', 'drafts'],
queryFn: () => authoringApi.listDrafts(),
meta: { skipGlobalErrorHandler: true }, retry: false,
})
const createDraft = useMutation({
mutationFn: (request: { namespaceSlug: string, name: string, requirement?: string }) =>
authoringApi.createDraft(request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
setCreateOpen(false)
toast.success(t('authoring.list.created'))
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : String(error))
},
})
const deleteDraft = useMutation({
mutationFn: (draftId: number) => authoringApi.deleteDraft(draftId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['authoring', 'drafts'] })
setDeleteTarget(null)
toast.success(t('authoring.list.deleted'))
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : String(error))
},
})
return (
<div className="space-y-8">
<DashboardPageHeader
title={t('authoring.list.title')}
subtitle={t('authoring.list.subtitle')}
actions={
<Button onClick={() => setCreateOpen(true)} data-testid="create-draft">
<Plus className="mr-1 h-4 w-4" aria-hidden />
{t('authoring.list.create')}
</Button>
}
/>
<Card>
<CardContent className="pt-6">
{drafts.isLoading && <p className="text-sm text-muted-foreground">{t('authoring.ui.loading')}</p>}
{drafts.isError && <QueryError error={drafts.error} retry={() => void drafts.refetch()} />}
{drafts.data && drafts.data.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">{t('authoring.list.empty')}</p>
)}
{drafts.data && drafts.data.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('authoring.list.colName')}</TableHead>
<TableHead>{t('authoring.list.colRevision')}</TableHead>
<TableHead>{t('authoring.list.colValidated')}</TableHead>
<TableHead>{t('authoring.list.colUpdated')}</TableHead>
<TableHead className="w-32">{t('authoring.list.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drafts.data.map((draft) => draft.id == null ? null : (
<TableRow key={draft.id} data-testid={`draft-row-${draft.id}`}>
<TableCell>
<Link
to="/dashboard/authoring/$draftId"
params={{ draftId: String(draft.id) }}
className="break-all font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{draft.name}
</Link>
{draft.requirement && (
<p className="max-w-md truncate text-xs text-muted-foreground">{draft.requirement}</p>
)}
</TableCell>
<TableCell>#{draft.revision}</TableCell>
<TableCell>
{draft.validated ? (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-950 dark:text-green-300">
{t('authoring.list.validated')}
</span>
) : (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{t('authoring.list.notValidated')}
</span>
)}
</TableCell>
<TableCell>{draft.updatedAt ? formatLocalDateTime(draft.updatedAt, i18n.language) : '—'}</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => setDeleteTarget(draft)}
data-testid={`delete-draft-${draft.id}`}
>
{t('authoring.ui.delete')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<CreateDraftDialog
key={String(createOpen)}
open={createOpen}
onOpenChange={setCreateOpen}
submitting={createDraft.isPending}
onSubmit={(request) => createDraft.mutate(request)}
/>
<ConfirmDialog
open={deleteTarget != null}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null)
}
}}
title={t('authoring.list.deleteTitle')}
description={t('authoring.list.deleteDescription', { name: deleteTarget?.name ?? '' })}
variant="destructive"
onConfirm={async () => {
if (deleteTarget?.id == null) return
try { await deleteDraft.mutateAsync(deleteTarget.id) } catch { /* Keep the confirmation open on failure. */ }
}}
closeOnConfirm={false}
/>
</div>
)
}
function CreateDraftDialog({
open,
onOpenChange,
submitting,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
submitting: boolean
onSubmit: (request: { namespaceSlug: string, name: string, requirement?: string }) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [namespaceSlug, setNamespaceSlug] = useState('')
const [requirement, setRequirement] = useState('')
const namespaces = useQuery({
queryKey: ['authoring', 'namespaces'],
queryFn: () => namespaceApi.listMine(),
enabled: open,
meta: { skipGlobalErrorHandler: true }, retry: false,
})
const submit = () => {
if (!namespaceSlug || !name.trim()) {
return
}
onSubmit({ namespaceSlug, name: name.trim(), requirement: requirement.trim() || undefined })
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (submitting) return
if (!nextOpen) {
setName('')
setNamespaceSlug('')
setRequirement('')
}
onOpenChange(nextOpen)
}}
>
<DialogContent hideClose={submitting}>
<DialogHeader>
<DialogTitle>{t('authoring.list.createTitle')}</DialogTitle>
<DialogDescription>{t('authoring.list.createDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="draft-namespace">{t('authoring.list.fieldNamespace')}</Label>
{namespaces.isError && <QueryError error={namespaces.error} retry={() => void namespaces.refetch()} />}
{namespaces.isLoading && <p role="status">{t('authoring.ui.loading')}</p>}
<Select disabled={submitting || namespaces.isLoading} value={namespaceSlug} onValueChange={setNamespaceSlug}>
<SelectTrigger id="draft-namespace" data-testid="draft-namespace">
<SelectValue placeholder={t('authoring.list.namespacePlaceholder')} />
</SelectTrigger>
<SelectContent>
{(namespaces.data ?? []).map((namespace) => (
<SelectItem key={namespace.slug} value={namespace.slug}>
{namespace.displayName || namespace.slug}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="draft-name">{t('authoring.list.fieldName')}</Label>
<Input
disabled={submitting}
id="draft-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="document-summarizer"
data-testid="draft-name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draft-requirement">{t('authoring.list.fieldRequirement')}</Label>
<Textarea
disabled={submitting}
id="draft-requirement"
rows={3}
value={requirement}
onChange={(event) => setRequirement(event.target.value)}
placeholder={t('authoring.list.requirementPlaceholder')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>
{t('authoring.ui.cancel')}
</Button>
<Button onClick={submit} disabled={submitting || !namespaceSlug || !name.trim()} data-testid="submit-create-draft">
{submitting ? t('authoring.ui.submitting') : t('authoring.ui.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -7,7 +7,7 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import {
Star, Heart, Package, Boxes, Key, Shield, Flag, Globe,
UserCog, Lock, Bell, Clock, ChevronDown, ChevronRight, Github,
UserCog, Lock, Bell, Clock, ChevronDown, ChevronRight, Github, PenTool,
} from 'lucide-react'
/**
@ -52,6 +52,7 @@ export const SIDEBAR_GROUPS: SidebarGroup[] = [
{ key: 'stars', icon: Star, label: 'sidebar.stars', to: '/dashboard/stars', analyticsId: 'navigation.starred.open' },
{ key: 'subscriptions', icon: Heart, label: 'sidebar.subscriptions', to: '/dashboard/subscriptions', analyticsId: 'navigation.subscriptions.open' },
{ key: 'tokens', icon: Key, label: 'sidebar.tokens', to: '/dashboard/tokens', analyticsId: 'account.tokens.open' },
{ key: 'authoring', icon: PenTool, label: 'sidebar.authoring', to: '/dashboard/authoring', analyticsId: 'navigation.authoring.open' },
{ key: 'reviewProgress', icon: Clock, label: 'sidebar.reviewProgress', to: '/dashboard/review-progress', analyticsId: 'review.progress.open' },
],
},