fix(review): integrate request context and version evidence

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-05 18:33:05 +08:00
parent 7303814452
commit 70da22c86e
18 changed files with 428 additions and 182 deletions

View file

@ -39,8 +39,8 @@
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":598},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":31,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":606},"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":628},"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":635},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":483,"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":496,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":635},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":472,"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":485,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":651},"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":658},"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":668},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
@ -277,14 +277,14 @@
{"file":"web/src/features/review/review-center-filter-form.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.filter.reset"]},
{"file":"web/src/features/review/review-center-filter-form.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.filter.apply"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":47,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.content.retry"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":75,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.files.expand"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":79,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":96,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":109,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":122,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":139,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":139,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":139,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":76,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.files.expand"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":80,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":97,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":110,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":123,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":141,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":141,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]},
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":141,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]},
{"file":"web/src/features/search/search-bar.tsx","line":55,"host":"form","prop":"data-analytics-id","event":"submit","ids":[],"conditional":true,"forward":"analyticsId"},
{"file":"web/src/features/search/search-bar.tsx","line":67,"host":"button","prop":"data-analytics-id","event":"click","ids":["search.query.clear"],"conditional":true},
{"file":"web/src/features/security-audit/security-audit-section.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.findings.toggle"]},
@ -670,22 +670,22 @@
{"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["report.archive.confirm","report.dismiss.confirm","report.hide.confirm","report.resolve.confirm"],"conditional":true},
{"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["report.archive.cancel","report.dismiss.cancel","report.hide.cancel","report.resolve.cancel"],"conditional":true},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":213,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":225,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":234,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.source.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":243,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":266,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":267,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":268,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":269,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":307,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":392,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":403,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":418,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":429,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":461,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":461,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":470,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":470,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":229,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":236,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":247,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.source.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":256,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":257,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":258,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":259,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":297,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":381,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":392,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":407,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":418,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":450,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":450,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":459,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]},
{"file":"web/src/pages/dashboard/review-detail.tsx","line":459,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]},
{"file":"web/src/pages/dashboard/review-progress.tsx","line":79,"host":"form","prop":"data-analytics-id","event":"submit","ids":["review.progress.search"]},
{"file":"web/src/pages/dashboard/review-progress.tsx","line":100,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.type.interact"]},
{"file":"web/src/pages/dashboard/review-progress.tsx","line":104,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]},

View file

@ -30,7 +30,7 @@ Editing filters does not change results until Search is submitted, which returns
Figure: A decision requires a pending request and current permission. Reading the old version does not verify the current submission.
1. Open a pending request and verify its submitter, subject, target version, visibility and namespace. GitHub-synchronized items also provide a content revision, source repository and fixed commit. The technical version used for installation appears in technical information.
2. Inspect the effective display name, summary and overview bound to this request, then expand the original package to inspect its files. Neither the old published version nor the original SKILL.md substitutes for custom public-facing content. The right task panel groups request type, scope, version and evidence. Avatar and visibility requests show their own objects; visibility is not a content version number. An empty preview caused by an object-storage read failure is not evidence of acceptable content.
2. Inspect the effective display name, summary and overview bound to this request, then expand the original package to inspect its files. Neither the old published version nor the original SKILL.md substitutes for custom public-facing content. The page header groups request type, scope, version, submitter and evidence. Avatar and visibility requests show their own objects; visibility is not a content version number. An empty preview caused by an object-storage read failure is not evidence of acceptable content.
3. Check license evidence, the scanning report and findings in Source & Security. Distinguish execution errors, risk findings and unconfigured analyzers. Ordinary version approval is unavailable while scanning is running. Unrecognized licenses or missing evidence require inspection of the specific status and supporting material; they do not confirm permission to use the content.
4. For avatar or visibility changes, verify the candidate and requested target. For a Skill Suite, inspect the pending snapshot, Entry Skill and fixed member versions. Approval is unavailable before the snapshot loads. Member approval does not approve the Suite. A Suite definition and a Suite Bundle import operation are separate objects; see [Skill Suites](./skill-suites.html).
5. Read previous decisions in Review History, then approve or reject after evidence checks. A rejection must identify the file, problem and correction conditions; approval comments are optional. Switching tabs preserves comments; switching requests clears comments and unconfirmed dialogs. This version has no conditional-approval state.
@ -51,10 +51,12 @@ Online preview limits are listed together below. Exceeding a limit does not mean
These are frontend/API implementation limits, not review-page settings. Sources: [review detail page](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx) and [review API](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java). `GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` reads metadata, custom web/package presentation and the documentation path without fetching object-storage content; README or SKILL.md fallback content is loaded on demand; the default is `true`.
Switching tabs or opening evidence smoothly returns to the detail page top, including its heading; reduced motion uses direct positioning. Query completion and comment edits do not reset the reading position. The summary has a distinct section, with package download and file browsing available before the overview. Comparison uses the available main width and stacks on narrow screens. Content uses the main page scroll.
Switching tabs or opening evidence smoothly returns to the detail page top, including its heading; reduced motion uses direct positioning. Query completion and comment edits do not reset the reading position. The summary has a distinct section, with package download and file browsing available before the overview. All tabs share the same main width; comparison stacks on narrow screens. Content uses the main page scroll.
### Understand Changes
The version context directly shows the published baseline, pending version and available package metadata. An absent changelog does not create an empty section. Missing displayable declarations do not create empty disclosures; an unavailable snapshot does not establish that no declaration was submitted.
Ordinary updates compare the actual published baseline on the left with the pending version on the right: effective display name, summary and overview, plus added, modified and removed files. Initial publication has no published baseline; the page states this instead of inventing an empty-version comparison. GitHub updates retain fixed source revisions and line-by-line file comparison. Avatar and visibility requests compare their own candidate or target rather than content versions.
Switching to history cannot bypass inspection: approval stays unavailable when required content, comparison or Suite snapshots cannot be read. Processed requests are read-only. A listed request does not itself grant decision permission. Search index maintenance is in system settings, outside review tasks.

View file

@ -30,7 +30,7 @@
图:申请仍为待审且具备处理权限时才可作出决定;读取旧版本不等于核对本次申请。
1. 在待审列表打开申请,核对提交者、对象、目标版本、可见范围及命名空间。GitHub 同步项另有内容修订号、来源仓库和固定 commit;安装使用的技术版本在技术信息中查看。
2. 在内容页签先核对本次生效的展示名称、简要描述和详细概述,再展开原始技能包核对文件。网页自定义内容必须与本次申请绑定,不能以旧发布版或原始 SKILL.md 替代。右侧任务信息集中展示申请事项、范围、版本和证据状态;头像及可见范围申请按自身对象展示,不能把可见范围当作内容版本号。对象存储读取失败时不得以空白预览判定内容合格。
2. 在内容页签先核对本次生效的展示名称、简要描述和详细概述,再展开原始技能包核对文件。网页自定义内容必须与本次申请绑定,不能以旧发布版或原始 SKILL.md 替代。顶部申请信息集中展示事项、范围、版本、提交人及证据状态;头像及可见范围申请按自身对象展示,不能把可见范围当作内容版本号。对象存储读取失败时不得以空白预览判定内容合格。
3. 在“来源与安全”核对许可证依据、扫描报告和发现项。区分执行失败、风险发现和分析器未配置。普通版本扫描未结束不能批准;许可证未识别或依据缺失时,须读取具体状态及证据,不能将其解释为许可已确认。
4. 若有头像或可见范围变更,核对申请中的候选和目标;若为技能套装(Skill Suite),核对待审快照、入口技能及成员固定版本。快照未加载时批准不可用,成员获批不等于套装获批。套装定义与套装 Bundle 导入操作不是同一对象,见[技能套装](./skill-suites.html)。
5. 在“审核记录”读取历史意见,完成证据检查后提交批准或拒绝。拒绝须写明具体文件、问题和修订条件;批准意见可选。切换页签保留意见,切换申请会清空意见和未确认弹窗。本版不提供“附条件批准”状态。
@ -51,10 +51,12 @@
这些是前端/接口实现限制,不由审核页面配置;依据[审核详情](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx)及[审核接口](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java)。`GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` 读取元数据、网页或包内自定义展示内容及说明路径,不读取对象存储正文;README 或 SKILL.md 回退正文需按需读取;缺省为 `true`。
切换内容页签或点击核查入口时,页面平滑回到包含标题的详情页顶部;系统开启减少动态效果时直接定位。异步加载及填写审核意见不重复改变阅读位置。简介独立展示,下载与文件浏览位于内容顶部;变更对比使用主区域全宽,窄屏上下排列。正文随主页面滚动。
切换内容页签或点击核查入口时,页面平滑回到包含标题的详情页顶部;系统开启减少动态效果时直接定位。异步加载及填写审核意见不重复改变阅读位置。简介独立展示,下载与文件浏览位于内容顶部;所有页签使用统一主宽,窄屏对比上下排列。正文随主页面滚动。
### 理解变更对比
版本关系直接展示已发布基线、本次待审版本及已读取的包信息;未填写的变更说明不生成空区域。无可展示合规声明时不保留空折叠框;快照不可用不等于已确认没有声明。
普通技能更新的“变更对比”以真实已发布基线为左侧,以本次待审版本为右侧,比较有效展示名称、简要描述、详细概述,并列出新增、修改和删除的文件。首次发布没有已发布基线,页面明确说明,不生成虚假的空版本差异。GitHub 更新保留固定来源修订及逐行文件比较;头像和可见范围申请比较各自候选或目标,不生成内容版本差异。
切换到历史记录不能绕过内容核查:本次正文、比较或套装快照读取失败时,批准保持不可用。已处理申请只读;列表可见不代表当前账号有决定权限。搜索索引维护已移至系统设置,不属于审核任务。

View file

@ -2,11 +2,13 @@
## 权威设计来源
用户确认的Figma:列表筛选以v6原位尺寸修正稿为准,详情/右侧/治理以v4统一token稿为准。实现与验收对照这些明确尺寸和状态,不在实现时重新选择风格。
用户确认的Figma:列表筛选以v6原位尺寸修正稿为准,详情以v7信息整合与版本核查稿为准,治理沿用v4统一token稿。实现与验收对照这些明确尺寸和状态,不在实现时重新选择风格。
- 列表:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=79-19170
- 状态菜单:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=79-19397
- 详情:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=76-7438
- 详情:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=86-21193
- 版本对比:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=86-21510
- 合规与版本状态:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=86-22267
- 范围详情:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=76-7945
- 组件:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=77-18418
@ -20,15 +22,15 @@
查询草稿与已应用条件分离;点击查询统一应用、回第一页并写URL。重置恢复PENDING与其余空/全部范围并查询。列表、详情返回和浏览器前进后退保留有效范围、条件、排序与页码。旧sync队列链接映射来源条件;命名空间旧入口进入同一列表预选slug。保留独立资料审核,不把资料头像当技能头像。
详情顶部仅任务标题与事项,主区有效简介/概述及原文件,右侧标准Card集中摘要和证据。按任务决定Tab,包内容加载失败不能用已发布旧内容冒充;扫描、头像、许可证和并发决定仍遵守原服务条件。已有任务已处理后只读。
详情顶部整合标题、事项、状态、坐标、绑定版本、可见范围、提交人与时间,扫描证据保留轻量入口;全部Tab使用统一主宽,不再保留申请侧栏或大折叠框。主区展示有效简介/概述及独立原文件。按任务决定Tab,包内容加载失败不能用已发布旧内容冒充;扫描、头像、许可证和并发决定仍遵守原服务条件。已有任务已处理后只读。
### 人工预览后的内容层次与定位修订
Tab导航与正文保留统一边界。简介使用独立浅底摘要区;详细概述保留清晰的正文标题与阅读间距。下载与浏览原文件入口放在内容顶部,文件区在简介之后、概述之前按需展开,不要求读完长概述后寻找入口。
变更对比占详情可用主区域全宽,申请摘要的折叠入口位于标题之后、对比之前;其他Tab保留双栏。对比按容器宽度自适应并排或上下展示,避免窄列。已修改字段默认展开,以状态文字和修改后底色凸显;未变化字段可展开查看,缺失概述不得标为未变化。Markdown字号仅在比较区局部收敛。
全部Tab保持同一主宽,申请信息固定属于页头。变更对比以已发布基线→本次待审及各自实际文件数、包大小和本次变更说明表达版本关系;无值不补0或空版本框,取消全量版本历史折叠。对比按容器宽度自适应并排或上下展示,避免窄列。已修改字段默认展开,以状态文字和修改后底色凸显;未变化字段可展开查看,缺失概述不得标为未变化。Markdown字号仅在比较区局部收敛。
用户切换Tab或点击右侧证据入口后,平滑回到详情页顶部(包含标题),而非仅对齐Tab容器;减少动态偏好下直接定位。重复点击当前证据入口同样响应,并将焦点移至所选页签;普通页签导航保留自身焦点。异步查询完成、首次渲染及审核意见输入不重复触发定位,不维护动画队列。
用户切换Tab或点击页头证据入口后,平滑回到详情页顶部(包含标题),而非仅对齐Tab容器;减少动态偏好下直接定位。重复点击当前证据入口同样响应,并将焦点移至所选页签;普通页签导航保留自身焦点。异步查询完成、首次渲染及审核意见输入不重复触发定位,不维护动画队列。
## 取舍
@ -37,3 +39,8 @@ Tab导航与正文保留统一边界。简介使用独立浅底摘要区;详
## 验证
覆盖筛选查询/重置/URL与分页、平台及TEAM权限隔离、用户资料权限、原文与自定义内容、基线及首次无基线、元数据/套装/GitHub任务、失败重试/已处理/并发、菜单尺寸/键盘/窄屏。使用真实本地Compose、S3依赖和浏览器完成端到端验收;测试矩阵与结果以交付报告链接回写,不把旧截图当新实现验收。
## 合规呈现边界
仅服务端提供的comparison.baseVersion作为比较基线,不从其他已发布版本补基线。当前有效声明在技能内容中可核查;两侧有效快照发生变更时展示差异,相同只保留一行;没有可展示声明时不生成空模块。一侧快照不可用时不能以空集合推断新增/移除,只展示已读取声明并说明无法完整比较。现有null投影无法区分未提供与解析失败,不夸大为已确认没有声明;请求级失败沿用错误和重试。

View file

@ -51,7 +51,7 @@ A normal version update MUST compare the task-bound presentation and package fil
- **THEN** the full pending content is reviewable without a fabricated comparison tab
### Requirement: Task-specific workbench layout
The confirmed dual-column detail MUST put readable target content in the main column and concise task/scope/version/evidence in the side card. The neutral segmented navigation MUST show only applicable sections and wrap without internal vertical scrolling on narrow screens. Approval/rejection, permissions, required reasons and processed read-only states MUST preserve existing domain rules.
The v7 detail MUST integrate title, task metadata and evidence into one page header. All tabs MUST share the same available main width without a review-summary side card or oversized disclosure. The neutral segmented navigation MUST show only applicable sections and wrap without internal vertical scrolling on narrow screens. Approval/rejection, permissions, required reasons and processed read-only states MUST preserve existing domain rules.
#### Scenario: Inspect a visibility expansion
- **WHEN** its task is viewed
@ -63,7 +63,7 @@ The confirmed dual-column detail MUST put readable target content in the main co
The review detail MUST group the navigation and active content in one bounded surface without a nested page scrollbar. A user-initiated tab switch or evidence shortcut MUST return to the detail page top, including its heading, after the new panel commits, including selecting the current evidence tab again. Query completion, initial rendering and comment edits MUST NOT trigger this navigation scroll.
#### Scenario: A long comparison is replaced with a short history panel
- **WHEN** the reviewer switches from a scrolled long comparison to history or opens scan evidence from the side panel
- **WHEN** the reviewer switches from a scrolled long comparison to history or opens scan evidence from the page header
- **THEN** the detail heading is visible at the page top rather than leaving the reviewer at the document bottom
- **AND** later query completion does not reset the reviewer's reading position
@ -77,3 +77,15 @@ The review detail MUST group the navigation and active content in one bounded su
- **THEN** the summary is visually distinct from the overview
- **AND** file browsing and package download are available before the long overview
- **AND** comparison uses the available main width with responsive columns and explicit changed states
### Requirement: Meaningful compliance and version sections
The version context MUST use only the server-selected baseline and task-bound version, with their actual package counts and sizes and the pending changelog when available. It MUST NOT render a full version-history disclosure or infer an arbitrary baseline. Columns MUST align at the top without stretching short content to match long content.
#### Scenario: There is no displayable compliance declaration
- **THEN** no empty compliance section is rendered
- **AND** a missing snapshot is not converted into an empty declaration to infer additions or removals
#### Scenario: Compliance evidence is incomplete
- **THEN** the available declaration remains inspectable with an incomplete-comparison message
- **AND** a first publication shows only its current declaration without a fabricated historical diff

View file

@ -1,7 +1,9 @@
/** @vitest-environment jsdom */
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SkillVersion } from '@/api/types'
import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel'
import { ReviewComplianceDiffPanel } from './review-compliance-diff-panel'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
@ -22,6 +24,8 @@ vi.mock('react-i18next', async () => {
}
})
afterEach(cleanup)
function createVersion(overrides: Partial<SkillVersion> = {}): SkillVersion {
return {
id: 1,
@ -37,11 +41,6 @@ function createVersion(overrides: Partial<SkillVersion> = {}): SkillVersion {
}
describe('ReviewComplianceDiffPanel', () => {
it('never uses a different unpublished version as the published baseline', () => {
const pending = createVersion({ id: 2, version: '1.1.0', status: 'PENDING_REVIEW' })
expect(pickBaseVersion([pending, createVersion({ status: 'REJECTED' })], '1.1.0')).toBeNull()
})
it('renders a clickable diff summary and item details', () => {
const html = renderToStaticMarkup(
<ReviewComplianceDiffPanel
@ -82,6 +81,75 @@ describe('ReviewComplianceDiffPanel', () => {
expect(html).toContain('review.complianceDiffViewDetails')
})
it('does not turn an unavailable snapshot into additions or removals', () => {
const snapshot = { schemaVersion: '1.0', digest: 'digest', items: [{ standard: 'soc2', controlId: 'CC1', evidence: [] }] }
for (const missing of ['base', 'pending']) {
const html = renderToStaticMarkup(<ReviewComplianceDiffPanel
baseVersion={createVersion({ complianceSnapshot: missing === 'base' ? undefined : snapshot })}
pendingVersion={createVersion({ version: '1.1.0', complianceSnapshot: missing === 'pending' ? undefined : snapshot })} />)
expect(html).toContain('reviewWorkbench.complianceIncomplete')
expect(html).toContain('CC1')
expect(html).not.toContain('review.complianceDiffAddedLabel')
expect(html).not.toContain('review.complianceDiffRemovedLabel')
}
})
it('shows a first-publication declaration without a fabricated historical comparison', () => {
const html = renderToStaticMarkup(<ReviewComplianceDiffPanel pendingVersion={createVersion({
complianceSnapshot: { items: [{ standard: 'soc2', controlId: 'CC1' }] },
})} />)
expect(html).toContain('CC1')
expect(html).not.toContain('reviewWorkbench.complianceIncomplete')
expect(html).not.toContain('review.complianceDiffAddedLabel')
})
it('warns when the server names a baseline but its version metadata is unavailable', () => {
const html = renderToStaticMarkup(<ReviewComplianceDiffPanel baselineExpected pendingVersion={createVersion({
complianceSnapshot: { items: [{ standard: 'soc2', controlId: 'CC1' }] },
})} />)
expect(html).toContain('reviewWorkbench.complianceIncomplete')
})
it('keeps identical populated declarations as a lightweight unchanged message', () => {
const version = createVersion({ complianceSnapshot: { items: [{ standard: 'soc2', controlId: 'CC1' }] } })
const html = renderToStaticMarkup(<ReviewComplianceDiffPanel baseVersion={version} pendingVersion={version} />)
expect(html).toContain('reviewWorkbench.complianceUnchanged')
expect(html).not.toContain('<details')
})
it('hides unavailable or empty declarations when neither side has displayable items', () => {
expect(renderToStaticMarkup(<ReviewComplianceDiffPanel baseVersion={createVersion()} pendingVersion={createVersion()} />)).toBe('')
})
it('labels the missing side neutrally after expanding additions and removals', () => {
const empty = { items: [] }
const populated = { items: [{ standard: 'soc2', controlId: 'CC1' }] }
for (const kind of ['added', 'removed']) {
const view = render(<ReviewComplianceDiffPanel
baseVersion={createVersion({ complianceSnapshot: kind === 'added' ? empty : populated })}
pendingVersion={createVersion({ version: '1.1.0', complianceSnapshot: kind === 'added' ? populated : empty })} />)
const toggle = screen.getByText('review.complianceDiffViewDetails').closest('summary')!
fireEvent.click(toggle)
expect(toggle.closest('details')?.open).toBe(true)
expect(screen.getByText(kind === 'added' ? 'reviewWorkbench.complianceAbsentBase' : 'reviewWorkbench.complianceAbsentPending')).toBeTruthy()
expect(screen.queryByText('review.complianceDiffBaseRemoved')).toBeNull()
expect(screen.queryByText('review.complianceDiffPendingAdded')).toBeNull()
view.unmount()
}
})
it('distinguishes a successfully read empty snapshot from an unavailable counterpart', () => {
const emptyVersion = createVersion({ complianceSnapshot: { items: [] } })
const missingSnapshot = createVersion()
for (const [base, pending] of [[emptyVersion, missingSnapshot], [missingSnapshot, emptyVersion], [null, emptyVersion], [emptyVersion, null]]) {
const html = renderToStaticMarkup(<ReviewComplianceDiffPanel baselineExpected baseVersion={base} pendingVersion={pending} />)
expect(html).toContain('reviewWorkbench.complianceIncomplete')
expect(html).not.toContain('<details')
expect(html).not.toContain('review.complianceDiffAddedLabel')
expect(html).not.toContain('review.complianceDiffRemovedLabel')
}
})
it('renders nothing when there is no diff', () => {
const html = renderToStaticMarkup(
<ReviewComplianceDiffPanel

View file

@ -1,6 +1,7 @@
import { ChevronDown, ShieldAlert, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ComplianceMapping, ComplianceSnapshot, SkillVersion } from '@/api/types'
import { ComplianceSnapshotPanel } from '@/features/skill/compliance-snapshot-panel'
import { cn } from '@/shared/lib/utils'
interface ReviewComplianceDiffPanelProps {
@ -8,6 +9,7 @@ interface ReviewComplianceDiffPanelProps {
pendingVersion?: SkillVersion | null
className?: string
concealVersionIdentifiers?: boolean
baselineExpected?: boolean
}
type DiffKind = 'added' | 'removed' | 'modified'
@ -52,7 +54,7 @@ function mappingSignature(mapping: ComplianceMapping) {
})
}
function compareComplianceSnapshots(baseSnapshot?: ComplianceSnapshot | null, pendingSnapshot?: ComplianceSnapshot | null) {
function compareComplianceSnapshots(baseSnapshot: ComplianceSnapshot, pendingSnapshot: ComplianceSnapshot) {
const baseItems = new Map((baseSnapshot?.items ?? []).map((item) => [mappingKey(item), item]))
const pendingItems = new Map((pendingSnapshot?.items ?? []).map((item) => [mappingKey(item), item]))
const keys = new Set<string>([...baseItems.keys(), ...pendingItems.keys()])
@ -219,7 +221,7 @@ function DiffItem({ entry }: { entry: DiffEntry }) {
</div>
<MappingDetails
mapping={entry.base}
emptyMessage={t('review.complianceDiffBaseRemoved')}
emptyMessage={t('reviewWorkbench.complianceAbsentBase')}
/>
</div>
<div className="space-y-2">
@ -228,7 +230,7 @@ function DiffItem({ entry }: { entry: DiffEntry }) {
</div>
<MappingDetails
mapping={entry.pending}
emptyMessage={t('review.complianceDiffPendingAdded')}
emptyMessage={t('reviewWorkbench.complianceAbsentPending')}
/>
</div>
</div>
@ -236,30 +238,32 @@ function DiffItem({ entry }: { entry: DiffEntry }) {
)
}
function pickBaseVersion(versions: SkillVersion[], activeVersion: string) {
const publishedVersions = versions.filter((version) => version.status === 'PUBLISHED' && version.version !== activeVersion)
if (publishedVersions.length > 0) {
return publishedVersions.sort((left, right) => {
const leftTime = new Date(left.publishedAt).getTime()
const rightTime = new Date(right.publishedAt).getTime()
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return rightTime - leftTime
}
return right.id - left.id
})[0]
}
return null
}
export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, className, concealVersionIdentifiers = false }: ReviewComplianceDiffPanelProps) {
export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, className, concealVersionIdentifiers = false, baselineExpected = false }: ReviewComplianceDiffPanelProps) {
const { t } = useTranslation()
if (!baseVersion || !pendingVersion) {
return null
const baseSnapshot = baseVersion?.complianceSnapshot
const pendingSnapshot = pendingVersion?.complianceSnapshot
const baseItems = baseSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
const pendingItems = pendingSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
const incompleteComparison = (baselineExpected || !!baseVersion)
&& (!baseVersion || !pendingVersion || !baseSnapshot || !pendingSnapshot)
if (!baseItems.length && !pendingItems.length) {
return incompleteComparison && (baseSnapshot || pendingSnapshot)
? <p role="status" className="text-sm text-muted-foreground">{t('reviewWorkbench.complianceIncomplete')}</p>
: null
}
const diff = compareComplianceSnapshots(baseVersion.complianceSnapshot, pendingVersion.complianceSnapshot)
// Missing snapshots are not empty declarations. Only compare two successfully projected snapshots.
if (!baseVersion || !pendingVersion || !baseSnapshot || !pendingSnapshot) {
const incomplete = baselineExpected || !!baseVersion
return <section className="space-y-3" aria-label={t('compliance.title')}>
{incomplete && <p role="status" className="rounded-lg border border-border/60 bg-secondary/50 p-3 text-sm text-muted-foreground">{t('reviewWorkbench.complianceIncomplete')}</p>}
{baseItems.length > 0 && <div className="space-y-2"><h3 className="text-sm font-semibold">{t('review.complianceDiffBaseVersion')}</h3><ComplianceSnapshotPanel snapshot={baseSnapshot} /></div>}
{pendingItems.length > 0 && <div className="space-y-2"><h3 className="text-sm font-semibold">{t('review.complianceDiffPendingVersion')}</h3><ComplianceSnapshotPanel snapshot={pendingSnapshot} /></div>}
</section>
}
const diff = compareComplianceSnapshots(baseSnapshot, pendingSnapshot)
if (diff.diffs.length === 0) {
return null
return <p className="text-sm text-muted-foreground">{t('reviewWorkbench.complianceUnchanged')}</p>
}
return (
@ -321,4 +325,4 @@ export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, classNa
)
}
export { compareComplianceSnapshots, pickBaseVersion }
export { compareComplianceSnapshots }

View file

@ -5,7 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReviewSkillDetail } from '@/api/types'
import { ReviewPresentationComparison } from './review-presentation-comparison'
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { count?: number }) => options?.count != null ? `${key}:${options.count}` : key }) }))
vi.mock('@/features/skill/markdown-renderer', () => ({ MarkdownRenderer: ({ content, className }: { content: string; className?: string }) => <div data-testid="comparison-markdown" className={className}>{content}</div> }))
afterEach(cleanup)
@ -24,6 +24,10 @@ function detail(): ReviewSkillDetail {
}
}
function version(number: string, fileCount: number, totalSize: number, changelog?: string): ReviewSkillDetail['versions'][number] {
return { id: 1, version: number, status: 'PUBLISHED', fileCount, totalSize, changelog, publishedAt: '2026-11-05T00:00:00Z', downloadAvailable: true }
}
describe('ReviewPresentationComparison', () => {
it('compares the server-selected published baseline with the bound review snapshot and file manifest', () => {
const html = renderToStaticMarkup(<ReviewPresentationComparison detail={detail()} />)
@ -91,6 +95,7 @@ describe('ReviewPresentationComparison', () => {
expect(oldSummary.parentElement).toBe(newSummary.parentElement)
expect(newSummary.parentElement?.className).toContain('grid-cols-[repeat(auto-fit,minmax(min(100%,28rem),1fr))]')
expect(newSummary.parentElement?.className).not.toContain('md:grid-cols-2')
expect(newSummary.parentElement?.className).toContain('items-start')
expect(newSummary.className).toContain('bg-amber-500/5')
expect(newSummary.className).toContain('dark:border-amber-400/30')
expect(oldSummary.className).not.toContain('bg-amber-500/5')
@ -114,4 +119,49 @@ describe('ReviewPresentationComparison', () => {
expect(screen.getAllByText('reviewWorkbench.modified').every((badge) => badge.className.includes('bg-amber-500/10'))).toBe(true)
})
it('matches the explicit baseline and pending version context, including genuine zero metrics', () => {
const value = detail()
value.versions = [version('9.0.0', 999, 999999), version('1.1.0', 0, 0, 'Current release notes'), version('1.0.0', 3, 2048, 'Old release notes')]
render(<ReviewPresentationComparison detail={value} />)
const baseline = screen.getByText('reviewWorkbench.baseline').parentElement!
const pending = screen.getByText('reviewWorkbench.pending').parentElement!
expect(baseline.textContent).toContain('1.0.0')
expect(baseline.textContent).toContain('skillDetail.fileCount:3')
expect(baseline.textContent).toContain('2.0 KB')
expect(pending.textContent).toContain('1.1.0')
expect(pending.textContent).toContain('skillDetail.fileCount:0')
expect(pending.textContent).toContain('0 B')
expect(screen.getByText('suite.changelog').parentElement?.textContent).toContain('Current release notes')
expect(screen.queryByText('Old release notes')).toBeNull()
expect(screen.queryByText('9.0.0')).toBeNull()
})
it('omits unavailable metrics and blank release notes without borrowing overview or another version', () => {
const value = detail()
value.versions = [version('1.1.0', 1, 1024, ' '), version('9.0.0', 999, 999999, 'Unrelated notes')]
render(<ReviewPresentationComparison detail={value} />)
const baseline = screen.getByText('reviewWorkbench.baseline').parentElement!
expect(baseline.textContent).toContain('1.0.0')
expect(baseline.textContent).not.toContain('skillDetail.fileCount')
expect(baseline.textContent).not.toContain(' B')
expect(screen.queryByText('suite.changelog')).toBeNull()
expect(screen.queryByText('Unrelated notes')).toBeNull()
expect(screen.getByText('New overview')).toBeTruthy()
})
it('keeps GitHub technical versions concealed while retaining the real before and after context', () => {
const value = detail()
value.activeVersion = '0.0.0-sync-pending'
value.comparison!.baseVersion = '0.0.0-sync-baseline'
value.versions = [version(value.activeVersion, 2, 1024), version(value.comparison!.baseVersion, 1, 512)]
render(<ReviewPresentationComparison detail={value} concealTechnicalVersions />)
expect(screen.getByText('reviewWorkbench.baseline')).toBeTruthy()
expect(screen.getByText('reviewWorkbench.pending')).toBeTruthy()
expect(screen.queryByText(value.activeVersion)).toBeNull()
expect(screen.queryByText(value.comparison!.baseVersion)).toBeNull()
expect(screen.getByText('skillDetail.fileCount:2')).toBeTruthy()
expect(screen.getByText('1.0 KB')).toBeTruthy()
})
})

View file

@ -1,4 +1,4 @@
import { ChevronDown } from 'lucide-react'
import { ArrowRight, ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ReviewSkillDetail } from '@/api/types'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
@ -6,9 +6,10 @@ import { cn } from '@/shared/lib/utils'
interface ReviewPresentationComparisonProps {
detail: ReviewSkillDetail
concealTechnicalVersions?: boolean
}
const COMPARISON_GRID = 'grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,28rem),1fr))] gap-3'
const COMPARISON_GRID = 'grid min-w-0 items-start grid-cols-[repeat(auto-fit,minmax(min(100%,28rem),1fr))] gap-3'
const COMPARISON_MARKDOWN = 'text-sm leading-6 [&_h1]:mb-4 [&_h1]:pb-2 [&_h1]:text-xl [&_h1]:leading-7 [&_h2]:mb-3 [&_h2]:mt-6 [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:leading-7 [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-base [&_h3]:leading-6 [&_h4]:text-sm [&_p]:my-3 [&_p]:text-sm [&_p]:leading-6 [&_li]:text-sm [&_li]:leading-6 [&_pre]:text-xs [&_table]:text-sm [&>:first-child]:mt-0 [&>:last-child]:mb-0'
const CHANGED_STATUS = 'border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200'
const FILE_STATUS: Record<string, string> = {
@ -17,12 +18,22 @@ const FILE_STATUS: Record<string, string> = {
REMOVED: 'border-red-500/30 bg-red-500/10 text-red-800 dark:text-red-200',
}
// Keep the existing file-tree 1024-unit convention local to this review context.
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Compares only snapshots authorized and selected by the review endpoint. */
export function ReviewPresentationComparison({ detail }: ReviewPresentationComparisonProps) {
export function ReviewPresentationComparison({ detail, concealTechnicalVersions = false }: ReviewPresentationComparisonProps) {
const { t } = useTranslation()
const comparison = detail.comparison
if (!comparison) return <p className="text-sm text-muted-foreground">{t('reviewWorkbench.noBaseline')}</p>
const baselineVersion = detail.versions.find((version) => version.version === comparison.baseVersion)
const pendingVersion = detail.versions.find((version) => version.version === detail.activeVersion)
const fields = [
{ key: 'displayName', title: t('publish.displayName'), markdown: false },
{ key: 'summary', title: t('reviewWorkbench.summary'), markdown: false },
@ -31,10 +42,23 @@ export function ReviewPresentationComparison({ detail }: ReviewPresentationCompa
return (
<section className="space-y-5" aria-label={t('reviewWorkbench.presentationChanges')}>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-secondary/70 p-4 text-sm">
<div><p className="text-xs text-muted-foreground">{t('reviewWorkbench.baseline')}</p><p className="mt-1 break-all font-medium">{comparison.baseVersion}</p></div>
<div><p className="text-xs text-muted-foreground">{t('reviewWorkbench.pending')}</p><p className="mt-1 break-all font-medium">{detail.activeVersion}</p></div>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg bg-secondary/70 p-4 text-sm sm:gap-5">
{([{ label: 'baseline', number: comparison.baseVersion, version: baselineVersion }, { label: 'pending', number: detail.activeVersion, version: pendingVersion }] as const).map(({ label, number, version }, index) => (
<div key={label} className={cn('min-w-0 space-y-2', index === 0 ? 'col-start-1 row-start-1' : 'col-start-3 row-start-1')}>
<p className="text-xs font-medium text-muted-foreground">{t(`reviewWorkbench.${label}`)}</p>
{!concealTechnicalVersions && <p className="break-all font-mono text-base font-semibold">{number}</p>}
{version && <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{version.fileCount != null && <span>{t('skillDetail.fileCount', { count: version.fileCount })}</span>}
{version.totalSize != null && <span>{formatFileSize(version.totalSize)}</span>}
</div>}
</div>
))}
<ArrowRight aria-hidden="true" className="col-start-2 row-start-1 mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
{pendingVersion?.changelog?.trim() && <section className="space-y-2 rounded-lg border border-border/60 px-4 py-3">
<h3 className="text-xs font-medium text-muted-foreground">{t('suite.changelog')}</h3>
<p className="whitespace-pre-wrap break-words text-sm leading-6">{pendingVersion.changelog}</p>
</section>}
{fields.map(({ key, title, markdown }) => {
const before = comparison.baseline[key]
const after = comparison.current[key]

View file

@ -3,7 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import type { ReviewSkillDetail } from '@/api/types'
import { ReviewSkillDetailSection } from './review-skill-detail-section'
import { ReviewSkillDetailSection, ReviewSkillChangesSection } from './review-skill-detail-section'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
@ -141,6 +141,45 @@ describe('ReviewSkillDetailSection', () => {
expect(screen.getByRole('link', { name: 'filePreview.downloadFile' })).toBeTruthy()
})
it('shows the task-bound declaration on content even when a first publication has no changes tab', () => {
const detail = createDetail()
detail.comparison = null
detail.versions[0].complianceSnapshot = { items: [{ standard: 'current-standard', controlId: 'CURRENT' }] }
detail.versions.push({ ...detail.versions[0], id: 9, version: '1.1.0', status: 'PUBLISHED', complianceSnapshot: { items: [{ standard: 'other-standard', controlId: 'OTHER' }] } })
render(<ReviewSkillDetailSection detail={detail} />)
expect(screen.getByText(/current-standard/)).toBeTruthy()
expect(screen.queryByText(/other-standard/)).toBeNull()
expect(screen.queryByText('reviewWorkbench.baseline')).toBeNull()
})
it('does not display an empty declaration shell or borrow another version declaration', () => {
const detail = createDetail()
detail.versions.push({ ...detail.versions[0], id: 9, version: '1.1.0', status: 'PUBLISHED', complianceSnapshot: { items: [{ standard: 'other-standard', controlId: 'OTHER' }] } })
const view = render(<ReviewSkillDetailSection detail={detail} />)
expect(screen.queryByText('compliance.title')).toBeNull()
expect(screen.queryByText(/other-standard/)).toBeNull()
detail.versions[0].complianceSnapshot = { items: [] }
view.rerender(<ReviewSkillDetailSection detail={detail} />)
expect(screen.queryByText('compliance.title')).toBeNull()
})
it('does not create empty compliance or full historical-version disclosures', () => {
render(<ReviewSkillChangesSection detail={createDetail()} />)
expect(screen.queryByText('review.complianceChanges')).toBeNull()
expect(screen.queryByText('skillDetail.tabVersions')).toBeNull()
expect(screen.queryByText('review.technicalDetails')).toBeNull()
})
it('does not take an arbitrary published version as an unavailable comparison baseline', () => {
const detail = createDetail()
detail.versions[0].complianceSnapshot = { items: [{ standard: 'pending-declaration', controlId: 'NEW' }] }
detail.versions.push({ ...detail.versions[0], id: 9, version: '1.1.0', status: 'PUBLISHED', complianceSnapshot: { items: [{ standard: 'unrelated-published', controlId: 'OLD' }] } })
render(<ReviewSkillChangesSection detail={detail} />)
expect(screen.getByText(/pending-declaration/)).toBeTruthy()
expect(screen.queryByText(/unrelated-published/)).toBeNull()
expect(screen.queryByText('review.complianceDiffRemovedLabel')).toBeNull()
})
it('renders loading state while review detail is being fetched', () => {
const html = renderToStaticMarkup(<ReviewSkillDetailSection isLoading />)

View file

@ -7,12 +7,12 @@ import { FileTree } from '@/features/skill/file-tree'
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
import { canPreviewFile } from '@/features/skill/file-type-utils'
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { ComplianceSnapshotPanel } from '@/features/skill/compliance-snapshot-panel'
import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { ReviewComplianceDiffPanel } from './review-compliance-diff-panel'
import { Button, buttonVariants } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { getReviewDownloadHref, getReviewSkillDocumentation, isActiveReviewVersion } from './review-skill-detail'
import { getReviewDownloadHref, getReviewSkillDocumentation } from './review-skill-detail'
import { useReviewFile } from './use-review-file'
import { ReviewPresentationComparison } from './review-presentation-comparison'
@ -52,6 +52,7 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
const summary = presentation?.summary ?? detail.skill.summary
const overview = presentation?.overview ?? ((presentation?.overviewSource === 'README' || presentation?.overviewSource === 'SKILL_MD' || !presentation) ? detail.documentationContent : null)
const downloadHref = getReviewDownloadHref(detail)
const currentCompliance = detail.versions.find((version) => version.version === detail.activeVersion)?.complianceSnapshot
const handleDownloadFile = () => {
if (!previewNode || !reviewId) return
const url = buildApiUrl(`${WEB_API_PREFIX}/reviews/${reviewId}/file?path=${encodeURIComponent(previewNode.path)}`)
@ -135,6 +136,7 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
: overview ? <MarkdownRenderer content={overview} />
: <p className="text-sm text-muted-foreground">{t(documentationTooLarge ? 'review.documentationTooLarge' : 'reviewWorkbench.notProvided')}</p>}
</section>
<ComplianceSnapshotPanel snapshot={currentCompliance} />
</section>
<FilePreviewDialog closeAnalyticsId="review.file.close" downloadAnalyticsId="review.file.download"
open={previewDialogOpen}
@ -157,39 +159,15 @@ export function ReviewSkillChangesSection({ detail, isLoading, hasError, onRetry
if (!detail) return null
const pendingVersion = detail.versions.find((version) => version.version === detail.activeVersion) ?? null
const baseVersion = detail.comparison ? detail.versions.find((version) => version.version === detail.comparison?.baseVersion) ?? null : pickBaseVersion(detail.versions, detail.activeVersion ?? '')
const versionHistory = (
<Card className="space-y-3 p-4">
<h3 className="text-base font-semibold">{t('skillDetail.tabVersions')}</h3>
{detail.versions.length > 0 ? detail.versions.map((version) => (
<div key={version.id} className="rounded-md border border-border p-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="break-all font-mono font-semibold">{version.version}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">{version.status}</span>
{isActiveReviewVersion(version, detail) && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{t('review.activeReviewVersion')}</span>}
<span className="ml-auto text-muted-foreground">{t('skillDetail.fileCount', { count: version.fileCount })}</span>
</div>
{version.changelog && <p className="mt-2 text-sm text-muted-foreground">{version.changelog}</p>}
<ComplianceSnapshotPanel snapshot={version.complianceSnapshot} />
</div>
)) : <p className="text-sm text-muted-foreground">{t('skillDetail.noVersions')}</p>}
</Card>
)
const baseVersion = detail.comparison
? detail.versions.find((version) => version.version === detail.comparison?.baseVersion) ?? null
: null
return (
<div className="space-y-4">
{(!concealTechnicalVersions || detail.comparison) && <ReviewPresentationComparison detail={detail} />}
<details className="rounded-md border border-border/60 p-4 text-sm">
<summary className="cursor-pointer font-medium">{t('review.complianceChanges')}</summary>
<div className="mt-4"><ReviewComplianceDiffPanel baseVersion={baseVersion} pendingVersion={pendingVersion}
concealVersionIdentifiers={concealTechnicalVersions} /></div>
</details>
{concealTechnicalVersions ? (
<details className="rounded-md border border-border bg-card p-4">
<summary className="cursor-pointer text-sm font-medium">{t('review.technicalDetails')}</summary>
<div className="mt-4">{versionHistory}</div>
</details>
) : <details className="rounded-md border border-border/60 p-4 text-sm"><summary className="cursor-pointer font-medium">{t('skillDetail.tabVersions')}</summary><div className="mt-4">{versionHistory}</div></details>}
<div className="space-y-6">
{(!concealTechnicalVersions || detail.comparison) && <ReviewPresentationComparison detail={detail} concealTechnicalVersions={concealTechnicalVersions} />}
<ReviewComplianceDiffPanel baseVersion={baseVersion} pendingVersion={pendingVersion}
baselineExpected={!!detail.comparison} concealVersionIdentifiers={concealTechnicalVersions} />
</div>
)
}

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReviewSkillDetail, ReviewTask } from '@/api/types'
import { Card } from '@/shared/ui/card'
import { SkillAvatar } from '@/features/skill/skill-avatar'
export function reviewRequestKey(review: ReviewTask, hasBaseline: boolean) {
if (review.subjectType === 'SUITE_VERSION') return 'suite'
@ -22,36 +22,51 @@ export function ReviewTaskStatus({ status }: { status: ReviewTask['status'] }) {
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-medium ${color}`}>{t(status === 'PENDING' ? 'review.statusPending' : status === 'APPROVED' ? 'review.statusApproved' : status === 'REJECTED' ? 'review.statusRejected' : 'review.statusSuperseded')}</span>
}
export function ReviewTaskSummary({ review, detail, hasBaseline, submittedAt, children }: {
export function ReviewTaskHeader({ review, detail, title, hasBaseline, submittedAt, resourceVisibility, children }: {
review: ReviewTask
detail?: ReviewSkillDetail
title: string
hasBaseline: boolean
submittedAt: string
resourceVisibility?: string | null
children?: ReactNode
}) {
const { t } = useTranslation()
const kind = reviewRequestKey(review, hasBaseline)
const metadataOnly = review.subjectType === 'SKILL_METADATA' && !review.requestedVisibility
const version = metadataOnly ? null : detail?.activeVersion ?? review.version
const visibility = review.requestedVisibility
? `${t(`skillVisibility.${review.previousVisibility}`)} → ${t(`skillVisibility.${review.requestedVisibility}`)}`
: (resourceVisibility || detail?.skill.visibility) ? t(`skillVisibility.${resourceVisibility || detail?.skill.visibility}`) : null
const rows = [
{ label: t('reviewWorkbench.request'), value: t(`reviewWorkbench.kind.${kind}`) },
...(!metadataOnly ? [{ label: t(review.requestedVisibility ? 'reviewWorkbench.boundVersion' : 'review.version'), value: detail?.activeVersion ?? review.version ?? '—' }] : []),
...(review.requestedVisibility ? [{ label: t('skillVisibility.title'), value: `${t(`skillVisibility.${review.previousVisibility}`)} → ${t(`skillVisibility.${review.requestedVisibility}`)}` }]
: detail ? [{ label: t('skillVisibility.title'), value: t(`skillVisibility.${detail.skill.visibility}`) }] : []),
...(version ? [{ label: t(review.requestedVisibility ? 'reviewWorkbench.boundVersion' : 'review.version'), value: version }] : []),
...(visibility ? [{ label: t('skillVisibility.title'), value: visibility }] : []),
{ label: t('review.submitter'), value: review.submittedByName || review.submittedBy },
{ label: t('review.submitTime'), value: submittedAt },
]
return (
<aside className="min-w-0 self-start xl:sticky xl:top-20" aria-label={t('reviewWorkbench.requestSummary')}>
<Card className="space-y-5 p-5">
<h2 className="text-lg font-semibold">{t('reviewWorkbench.requestSummary')}</h2>
<dl className="space-y-4">
{rows.map(({ label, value }) => <div key={label} className="grid grid-cols-[4rem_minmax(0,1fr)] gap-3 text-sm">
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words leading-5 [overflow-wrap:anywhere]">{value}</dd>
</div>)}
</dl>
{children && <div className="space-y-4 border-t border-border/60 pt-5">{children}</div>}
</Card>
</aside>
<header data-testid="review-task-header" className="min-w-0 space-y-5 border-b border-border/60 pb-5">
<div className="flex min-w-0 items-start gap-4">
<SkillAvatar name={title} url={detail?.skill.avatarUrl} seed={`${review.namespace}/${review.subjectSlug || review.skillSlug}`} size="h-11 w-11 text-lg" />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<h1 className="min-w-0 break-words text-[27px] font-medium leading-9 tracking-[-0.8px]">{title}</h1>
<ReviewTaskStatus status={review.status} />
</div>
<p className="break-all text-xs leading-5 text-muted-foreground">@{review.namespace}/{review.subjectSlug || review.skillSlug}</p>
</div>
</div>
<dl className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,9rem),1fr))] gap-x-6 gap-y-4">
{rows.map(({ label, value }) => <div key={label} className="min-w-0 space-y-1.5">
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
<dd className="break-words text-sm leading-5 [overflow-wrap:anywhere]">{value}</dd>
</div>)}
</dl>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-6 gap-y-3">
{children}
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{t('review.id')} #{review.id}</span>
</div>
</header>
)
}

View file

@ -3616,6 +3616,11 @@
"suite": "Approve suite version",
"sync": "Approve sync update"
},
"approveContentUnavailable": "Pending content is still loading or cannot be verified. Approval is unavailable."
"approveContentUnavailable": "Pending content is still loading or cannot be verified. Approval is unavailable.",
"viewScanEvidence": "View scan evidence",
"complianceIncomplete": "Some version declarations are unavailable, so a complete comparison is not possible.",
"complianceUnchanged": "Compliance declarations unchanged",
"complianceAbsentBase": "This declaration is absent from the baseline version.",
"complianceAbsentPending": "This declaration is absent from the current version."
}
}

View file

@ -3896,6 +3896,11 @@
"suite": "스킬 모음 버전 승인",
"sync": "동기화 업데이트 승인"
},
"approveContentUnavailable": "검토할 내용을 불러오는 중이거나 확인할 수 없습니다. 현재 승인할 수 없습니다."
"approveContentUnavailable": "검토할 내용을 불러오는 중이거나 확인할 수 없습니다. 현재 승인할 수 없습니다.",
"viewScanEvidence": "검사 근거 보기",
"complianceIncomplete": "일부 버전의 준수 선언을 사용할 수 없어 전체 비교가 불가능합니다.",
"complianceUnchanged": "준수 선언이 변경되지 않았습니다",
"complianceAbsentBase": "기준 버전에는 이 선언이 없습니다.",
"complianceAbsentPending": "현재 버전에는 이 선언이 없습니다."
}
}

View file

@ -3620,6 +3620,11 @@
"suite": "Одобрить версию набора",
"sync": "Одобрить обновление синхронизации"
},
"approveContentUnavailable": "Содержимое заявки ещё загружается или не может быть проверено. Одобрение недоступно."
"approveContentUnavailable": "Содержимое заявки ещё загружается или не может быть проверено. Одобрение недоступно.",
"viewScanEvidence": "Посмотреть результаты сканирования",
"complianceIncomplete": "Декларации соответствия для некоторых версий недоступны. Полное сравнение невозможно.",
"complianceUnchanged": "Декларации соответствия не изменились",
"complianceAbsentBase": "В базовой версии этой декларации нет.",
"complianceAbsentPending": "В текущей версии этой декларации нет."
}
}

View file

@ -3614,6 +3614,11 @@
"suite": "批准套装版本",
"sync": "批准同步更新"
},
"approveContentUnavailable": "待审内容尚未完成加载或无法核对,暂不能批准。"
"approveContentUnavailable": "待审内容尚未完成加载或无法核对,暂不能批准。",
"viewScanEvidence": "查看扫描证据",
"complianceIncomplete": "部分版本的合规声明不可用,无法完整比较。",
"complianceUnchanged": "合规声明未变化",
"complianceAbsentBase": "基线版本中没有此声明",
"complianceAbsentPending": "本次版本中没有此声明"
}
}

View file

@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReviewSkillDetail } from '@/api/types'
@ -302,7 +302,7 @@ describe('ReviewDetailPage', () => {
expect((screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }) as HTMLButtonElement).disabled).toBe(false)
})
it('renders a compact task header, pill navigation and a right-hand request summary', () => {
it('renders a unified task header, pill navigation and a full-width workspace', () => {
const html = renderToStaticMarkup(<ReviewDetailPage />)
expect(html).toContain('flex min-h-[calc(100dvh-5.25rem)] w-full flex-col')
@ -313,8 +313,8 @@ describe('ReviewDetailPage', () => {
expect(html).toContain('sticky bottom-0')
expect(html).toContain('review.commentLabel')
expect(html).toContain('reviewWorkbench.approve.')
expect(html).toContain('<aside')
expect(html).toContain('reviewWorkbench.requestSummary')
expect(html).not.toContain('<aside')
expect(html).toContain('data-testid="review-task-header"')
expect(html).not.toContain('overflow-x-auto')
})
@ -351,7 +351,7 @@ describe('ReviewDetailPage', () => {
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
expect(html).toContain('reviewWorkbench.requestSummary')
expect(html).toContain('data-testid="review-task-header"')
expect(html).toContain('demo-skill')
})
@ -378,7 +378,7 @@ describe('ReviewDetailPage', () => {
const html = renderToStaticMarkup(<ReviewDetailPage />)
expect(html).toContain('reviewWorkbench.requestSummary')
expect(html).toContain('data-testid="review-task-header"')
expect(navigateMock).not.toHaveBeenCalled()
})
@ -561,19 +561,17 @@ describe('ReviewDetailPage', () => {
expect(screen.getByRole('tab', { name: 'review.tabChanges' })).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'review.tabChanges' }))
expect(screen.getByTestId('review-detail-layout').className).not.toContain('268px')
const applicationDetails = screen.getByText('reviewWorkbench.requestSummary', { selector: 'summary' }).closest('details')
expect(applicationDetails?.open).toBe(false)
expect((applicationDetails?.compareDocumentPosition(screen.getByTestId('review-detail-workbench')) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
const header = screen.getByTestId('review-task-header')
expect(header.compareDocumentPosition(screen.getByTestId('review-detail-workbench')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByText('reviewWorkbench.requestSummary')).toBeNull()
expect(screen.getByText('Published summary')).toBeTruthy()
expect(screen.getByText('1.0.0')).toBeTruthy()
fireEvent.click(screen.getByText('reviewWorkbench.requestSummary', { selector: 'summary' }))
expect(applicationDetails?.open).toBe(true)
const scanShortcut = screen.getByRole('button', { name: /review.scanSummary/ })
scanShortcut.focus()
fireEvent.click(scanShortcut)
expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'review.tabSourceSecurity' }))
expect(scrollToMock).toHaveBeenLastCalledWith({ top: 0, behavior: 'smooth' })
expect(screen.getByTestId('review-detail-layout').className).toContain('268px')
expect(screen.getByTestId('review-detail-layout').className).not.toContain('268px')
})
it('keeps approval blocked after a failed comparison when switching to history', () => {
@ -730,4 +728,42 @@ describe('ReviewDetailPage', () => {
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'instant' })
})
it('keeps request metadata and scan evidence in the title header across tabs', () => {
render(<ReviewDetailPage />)
const header = screen.getByTestId('review-task-header')
expect(within(header).getByRole('heading', { level: 1, name: 'Demo Skill' })).toBeTruthy()
expect(within(header).getByText('reviewWorkbench.kind.publish')).toBeTruthy()
expect(within(header).getByText('@global/demo-skill')).toBeTruthy()
expect(within(header).getByText('Local Admin')).toBeTruthy()
expect(within(header).getByText('2026-03-19T00:00:00Z')).toBeTruthy()
expect(within(header).getByRole('button', { name: /review.scanSummary/ })).toBeTruthy()
const layout = screen.getByTestId('review-detail-layout').className
fireEvent.click(screen.getByRole('tab', { name: 'review.tabHistory' }))
expect(screen.getByTestId('review-detail-layout').className).toBe(layout)
expect(screen.getByTestId('review-task-header')).toBe(header)
expect(document.querySelector('aside')).toBeNull()
})
it('omits package version and scan claims from an independent avatar header', () => {
metadataDetail()
render(<ReviewDetailPage />)
const header = screen.getByTestId('review-task-header')
expect(within(header).getByText('reviewWorkbench.kind.avatar')).toBeTruthy()
expect(within(header).queryByText('review.version')).toBeNull()
expect(within(header).queryByText('reviewWorkbench.boundVersion')).toBeNull()
expect(within(header).queryByRole('button', { name: /review.scanSummary/ })).toBeNull()
})
it('uses the suite task version and snapshot scope without a cached skill version', () => {
const task = useReviewDetailMock() as { data: Record<string, unknown> }
useReviewDetailMock.mockReturnValue({ ...task, data: { ...task.data, subjectType: 'SUITE_VERSION', subjectSlug: 'suite-demo', version: '3.0.0' } })
useSuiteDetailMock.mockReturnValue({ data: { displayName: 'Snapshot suite', visibility: 'NAMESPACE_ONLY', members: [] }, isLoading: false, refetch: vi.fn() })
render(<ReviewDetailPage />)
const header = screen.getByTestId('review-task-header')
expect(within(header).getByRole('heading', { level: 1, name: 'Snapshot suite' })).toBeTruthy()
expect(within(header).getByText('3.0.0')).toBeTruthy()
expect(within(header).getByText('skillVisibility.NAMESPACE_ONLY')).toBeTruthy()
expect(within(header).queryByText('1.2.0')).toBeNull()
})
})

View file

@ -3,8 +3,8 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { buildReviewReturnPath } from '@/features/review/review-route-search'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { ArrowLeft } from 'lucide-react'
import { ReviewTaskStatus, ReviewTaskSummary, reviewRequestKey } from '@/features/review/review-task-summary'
import { ArrowLeft, ArrowRight, ShieldCheck } from 'lucide-react'
import { ReviewTaskHeader, reviewRequestKey } from '@/features/review/review-task-summary'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Textarea } from '@/shared/ui/textarea'
@ -218,24 +218,10 @@ function ReviewDetailScreen({
)
}
const requestSummary = (
<ReviewTaskSummary review={review} detail={reviewSkillDetail} hasBaseline={hasBaseline} submittedAt={formatDate(review.submittedAt)}>
{!isSuiteReview && !isMetadataReview && <div className="space-y-3">
<h3 className="text-base font-semibold">{t('reviewWorkbench.checks')}</h3>
<button data-analytics-id="review.security.open" type="button" onClick={() => selectTab('source', true)} className="block w-full space-y-2 text-left text-sm underline-offset-4 hover:underline">
<span className="block font-medium">{t('review.scanSummary')}</span>
{review.requestedVisibility
? <span className="block text-xs text-muted-foreground">{reviewSkillDetail?.visibilityChange ? t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`) : t('review.scanLoadFailed')}</span>
: isLoadingReviewSkillDetail || (skillId && versionId && isLoadingSecurityAudits) ? <span className="block text-xs text-muted-foreground">{t('review.scanLoading')}</span>
: reviewSkillDetailError || isSecurityAuditsError ? <span className="block text-xs text-destructive">{t('review.scanLoadFailed')}</span>
: securityAudits?.length ? <span className="flex flex-wrap gap-2">{securityAudits.map((audit) => <VerdictBadge key={audit.id} displayState={getSecurityAuditDisplayState(audit, activeReviewVersion?.status)} compact />)}</span>
: <span className="block text-xs text-muted-foreground">{t(activeReviewVersion?.status === 'SCANNING' ? 'review.scanPending' : activeReviewVersion?.status === 'SCAN_FAILED' ? 'review.scanFailed' : 'review.scanUnavailable')}</span>}
</button>
{isGithubSyncReview && <button type="button" data-analytics-id="review.source.open" onClick={() => selectTab('source', true)} className="space-y-1 text-left text-sm underline-offset-4 hover:underline"><span className="block font-medium">{t('review.githubLicenseStatus')}</span><span className="block text-xs text-muted-foreground">{githubSource ? t(`review.githubLicense${githubSource.evidence.status}`, { defaultValue: githubSource.evidence.status }) : t(isGithubSourceError ? 'review.githubSourceError' : 'review.scanLoading')}</span></button>}
<p className="text-xs leading-5 text-muted-foreground">{t('reviewWorkbench.scanBoundary')}</p>
</div>}
</ReviewTaskSummary>
)
const title = isSuiteReview
? reviewSuiteDetail?.displayName || review.displayName || review.subjectSlug || t('review.detail')
: reviewSkillDetail?.presentation?.displayName || reviewSkillDetail?.skill.displayName || review.displayName || review.subjectSlug || review.skillSlug || t('review.detail')
return (
<div className="flex min-h-[calc(100dvh-5.25rem)] w-full flex-col gap-4 animate-fade-up">
@ -243,23 +229,27 @@ function ReviewDetailScreen({
<Button data-analytics-id="review.list.open" variant="link" size="sm" className="h-auto px-0 py-0 text-xs" onClick={() => navigate({ to: returnTo })}>
<ArrowLeft className="h-3.5 w-3.5" />{t('review.backToList')}
</Button>
<div className="space-y-3">
<h1 className="break-words text-[27px] font-medium leading-9 tracking-[-0.8px]">{reviewSkillDetail?.presentation?.displayName || reviewSkillDetail?.skill.displayName || review.subjectSlug || review.skillSlug || t('review.detail')}</h1>
<div className="flex flex-wrap items-center gap-3">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium">{t(`reviewWorkbench.kind.${reviewRequestKey(review, hasBaseline)}`)}</span>
<ReviewTaskStatus status={review.status} />
<span className="min-w-0 break-all text-xs text-muted-foreground">@{review.namespace}/{review.subjectSlug || review.skillSlug}</span>
</div>
</div>
<ReviewTaskHeader review={review} detail={isSuiteReview ? undefined : reviewSkillDetail}
title={title} resourceVisibility={isSuiteReview ? reviewSuiteDetail?.visibility : undefined}
hasBaseline={hasBaseline} submittedAt={formatDate(review.submittedAt)}>
{!isSuiteReview && !isMetadataReview && <div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2">
<button data-analytics-id="review.security.open" type="button" onClick={() => selectTab('source', true)} className="inline-flex min-h-9 min-w-0 flex-wrap items-center gap-x-3 gap-y-2 rounded-sm text-left text-sm underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ShieldCheck className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="font-medium">{t('review.scanSummary')}</span>
{review.requestedVisibility
? <span className="text-xs text-muted-foreground">{reviewSkillDetail?.visibilityChange ? t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`) : t('review.scanLoadFailed')}</span>
: isLoadingReviewSkillDetail || (skillId && versionId && isLoadingSecurityAudits) ? <span className="text-xs text-muted-foreground">{t('review.scanLoading')}</span>
: reviewSkillDetailError || isSecurityAuditsError ? <span className="text-xs text-destructive">{t('review.scanLoadFailed')}</span>
: securityAudits?.length ? <span className="inline-flex flex-wrap gap-2">{securityAudits.map((audit) => <VerdictBadge key={audit.id} displayState={getSecurityAuditDisplayState(audit, activeReviewVersion?.status)} compact />)}</span>
: <span className="text-xs text-muted-foreground">{t(activeReviewVersion?.status === 'SCANNING' ? 'review.scanPending' : activeReviewVersion?.status === 'SCAN_FAILED' ? 'review.scanFailed' : 'review.scanUnavailable')}</span>}
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">{t('reviewWorkbench.viewScanEvidence')}<ArrowRight className="h-3 w-3" aria-hidden="true" /></span>
</button>
{isGithubSyncReview && <button type="button" data-analytics-id="review.source.open" onClick={() => selectTab('source', true)} className="inline-flex min-h-9 flex-wrap items-center gap-2 rounded-sm text-left text-sm underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><span className="font-medium">{t('review.githubLicenseStatus')}</span><span className="text-xs text-muted-foreground">{githubSource ? t(`review.githubLicense${githubSource.evidence.status}`, { defaultValue: githubSource.evidence.status }) : t(isGithubSourceError ? 'review.githubSourceError' : 'review.scanLoading')}</span><ArrowRight className="h-3 w-3" aria-hidden="true" /></button>}
</div>}
</ReviewTaskHeader>
</div>
{availableTab === 'changes' && (
<details className="rounded-xl border border-border/60 bg-card">
<summary className="cursor-pointer px-5 py-4 text-sm font-medium">{t('reviewWorkbench.requestSummary')} <span className="ml-3 text-xs font-normal text-muted-foreground">{reviewSkillDetail?.activeVersion ?? review.version}</span></summary>
<div className="max-w-sm p-4 pt-0">{requestSummary}</div>
</details>
)}
<div data-testid="review-detail-layout" className={`grid min-w-0 items-start gap-6 ${availableTab === 'changes' ? 'grid-cols-1' : 'xl:grid-cols-[minmax(0,1fr)_268px]'}`}>
<div className="order-2 min-w-0 xl:order-1">
<div data-testid="review-detail-layout" className="min-w-0">
<div className="min-w-0">
<Tabs data-testid="review-detail-workbench" ref={tabsRef} value={availableTab} onValueChange={selectTab} className="min-h-[28rem] min-w-0 rounded-xl border border-border/60 bg-card text-card-foreground [overflow-anchor:none]">
<div className="border-b border-border/60 p-3 sm:px-5 sm:py-4">
<TabsList className="grid w-full grid-cols-2 gap-1 rounded-2xl border-0 bg-secondary/70 p-1 sm:inline-flex sm:w-auto sm:flex-wrap sm:rounded-full" aria-label={t('reviewWorkbench.contentNavigation')}>
@ -367,7 +357,6 @@ function ReviewDetailScreen({
</div>
</Tabs>
</div>
{availableTab !== 'changes' && <div className="order-1 min-w-0 xl:order-2">{requestSummary}</div>}
</div>
<div className="sticky bottom-0 z-20 -mx-3 mt-auto border-t border-border bg-background px-3 py-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-5px_16px_-12px_rgb(0_0_0/0.28)] sm:-mx-4 sm:px-4">
@ -382,8 +371,8 @@ function ReviewDetailScreen({
placeholder={t('review.commentPlaceholder')}
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
className="min-h-[100px] resize-y text-sm"
rows={2}
className="min-h-[64px] resize-y text-sm"
/>
</div>
<div className="flex shrink-0 flex-wrap gap-2">