mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-19 17:32:19 +08:00
merge: bring main into wireup branch after #1307 Phase 7 squash-merge
#1307 (Phase 7) merged to main today, putting the Phase 7 files (reducer.ts, useCritiqueStream.ts, useCritiqueReplay.ts, state/ sse.ts plus their tests) on main as the squash-merge baseline. This branch already has those files plus the downstream review fixes from Phase 8 (#1314 commit966f9c31), Phase 9 (#1315 commit89956019), and Phase 10 (#1316 commit9b72b54) applied on top. The merge picks our version on every conflict so the review fixes survive; the squash-merged baseline contribution arrives once those PRs merge in turn. Non-conflicting main commits (READMEs, FileViewer i18n) flow in unchanged.
This commit is contained in:
@@ -789,7 +789,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
||||
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
|
||||
@@ -806,9 +806,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -715,7 +715,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
|
||||
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
|
||||
@@ -732,9 +732,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -776,7 +776,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
|
||||
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Contribuidores de Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Contribuidores de Open Design" />
|
||||
</a>
|
||||
|
||||
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
|
||||
@@ -793,9 +793,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -722,7 +722,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
|
||||
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Contributeurs Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Contributeurs Open Design" />
|
||||
</a>
|
||||
|
||||
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point d’entrée.
|
||||
@@ -739,9 +739,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -712,7 +712,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
||||
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design コントリビューター" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design コントリビューター" />
|
||||
</a>
|
||||
|
||||
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
|
||||
@@ -729,9 +729,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -713,7 +713,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
|
||||
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design 컨트리뷰터" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design 컨트리뷰터" />
|
||||
</a>
|
||||
|
||||
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
|
||||
@@ -730,9 +730,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1004,7 +1004,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
|
||||
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
|
||||
@@ -1021,9 +1021,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -719,7 +719,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
|
||||
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
</a>
|
||||
|
||||
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
|
||||
@@ -736,9 +736,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -718,7 +718,7 @@ Issues, PR, новые skills и новые design systems приветству
|
||||
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Contributors Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Contributors Open Design" />
|
||||
</a>
|
||||
|
||||
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
|
||||
@@ -735,9 +735,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -876,7 +876,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
|
||||
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
|
||||
@@ -893,9 +893,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -718,7 +718,7 @@ OD не зупиняється на коді. Та сама поверхня ч
|
||||
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Контриб'ютори Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Контриб'ютори Open Design" />
|
||||
</a>
|
||||
|
||||
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
|
||||
@@ -735,9 +735,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -711,7 +711,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
||||
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system,每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design 贡献者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design 贡献者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
|
||||
@@ -728,9 +728,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -778,7 +778,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
||||
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system,每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-11" alt="Open Design 貢獻者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-12" alt="Open Design 貢獻者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
|
||||
@@ -795,9 +795,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
||||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-11" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-12" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-12" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createPortal } from 'react-dom';
|
||||
import { APP_CHROME_FILE_ACTIONS_ID } from './AppChromeHeader';
|
||||
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
|
||||
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { useT, useI18n } from '../i18n';
|
||||
import type { Dict, Locale } from '../i18n/types';
|
||||
import {
|
||||
fetchLiveArtifact,
|
||||
fetchLiveArtifactCode,
|
||||
@@ -1046,20 +1046,44 @@ function formatAbsoluteDateTime(iso: string | number | undefined): string | null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string | number | undefined, now = Date.now()): string | null {
|
||||
function formatRelativeTime(
|
||||
iso: string | number | undefined,
|
||||
now = Date.now(),
|
||||
locale: Locale = 'en',
|
||||
t?: TranslateFn,
|
||||
): string | null {
|
||||
if (iso === undefined || iso === null) return null;
|
||||
const ms = typeof iso === 'number' ? iso : new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return null;
|
||||
const deltaSec = Math.round((ms - now) / 1000);
|
||||
const abs = Math.abs(deltaSec);
|
||||
const suffix = deltaSec <= 0 ? ' ago' : ' from now';
|
||||
if (abs < 5) return 'just now';
|
||||
if (abs < 60) return `${abs}s${suffix}`;
|
||||
if (abs < 3600) return `${Math.round(abs / 60)}m${suffix}`;
|
||||
if (abs < 86400) return `${Math.round(abs / 3600)}h${suffix}`;
|
||||
if (abs < 86400 * 30) return `${Math.round(abs / 86400)}d${suffix}`;
|
||||
if (abs < 86400 * 365) return `${Math.round(abs / (86400 * 30))}mo${suffix}`;
|
||||
return `${Math.round(abs / (86400 * 365))}y${suffix}`;
|
||||
if (abs < 5) {
|
||||
// "just now" lives in the i18n dict because Intl.RelativeTimeFormat's
|
||||
// "0 seconds ago" reads awkwardly in narrow style and we want a
|
||||
// single canonical translation per locale. Fall back to the English
|
||||
// literal only when called without t (background utilities, tests).
|
||||
return t ? t('liveArtifact.refresh.justNow') : 'just now';
|
||||
}
|
||||
// Intl.RelativeTimeFormat handles tense (past / future), pluralisation,
|
||||
// and word-order per locale so the panel matches the rest of the
|
||||
// localised UI instead of mixing in English units like `5s ago`.
|
||||
// `style: 'narrow'` keeps the English output close to the historical
|
||||
// `5s ago` shape; `numeric: 'always'` forces numeric output so we
|
||||
// don't get "yesterday" / "now" mixed in unexpectedly with the
|
||||
// bucketing above.
|
||||
let rtf: Intl.RelativeTimeFormat;
|
||||
try {
|
||||
rtf = new Intl.RelativeTimeFormat(locale, { style: 'narrow', numeric: 'always' });
|
||||
} catch {
|
||||
rtf = new Intl.RelativeTimeFormat('en', { style: 'narrow', numeric: 'always' });
|
||||
}
|
||||
const value = deltaSec; // negative = past, positive = future
|
||||
if (abs < 60) return rtf.format(value, 'second');
|
||||
if (abs < 3600) return rtf.format(Math.round(value / 60), 'minute');
|
||||
if (abs < 86400) return rtf.format(Math.round(value / 3600), 'hour');
|
||||
if (abs < 86400 * 30) return rtf.format(Math.round(value / 86400), 'day');
|
||||
if (abs < 86400 * 365) return rtf.format(Math.round(value / (86400 * 30)), 'month');
|
||||
return rtf.format(Math.round(value / (86400 * 365)), 'year');
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number | undefined): string | null {
|
||||
@@ -1077,48 +1101,76 @@ interface RefreshStatusDescriptor {
|
||||
description: string;
|
||||
}
|
||||
|
||||
function describeRefreshStatus(status: LiveArtifactRefreshStatus): RefreshStatusDescriptor {
|
||||
function describeRefreshStatus(
|
||||
status: LiveArtifactRefreshStatus,
|
||||
t: TranslateFn,
|
||||
): RefreshStatusDescriptor {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
label: 'Refreshing',
|
||||
label: t('liveArtifact.refresh.statusRunning'),
|
||||
tone: 'running',
|
||||
description: 'A refresh run is currently in progress.',
|
||||
description: t('liveArtifact.refresh.statusRunningDescription'),
|
||||
};
|
||||
case 'succeeded':
|
||||
return {
|
||||
label: 'Up to date',
|
||||
label: t('liveArtifact.refresh.statusSucceeded'),
|
||||
tone: 'success',
|
||||
description: 'The last refresh finished successfully.',
|
||||
description: t('liveArtifact.refresh.statusSucceededDescription'),
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
label: 'Refresh failed',
|
||||
label: t('liveArtifact.refresh.statusFailed'),
|
||||
tone: 'error',
|
||||
description: 'The last refresh attempt did not complete successfully.',
|
||||
description: t('liveArtifact.refresh.statusFailedDescription'),
|
||||
};
|
||||
case 'idle':
|
||||
return {
|
||||
label: 'Ready to refresh',
|
||||
label: t('liveArtifact.refresh.statusReady'),
|
||||
tone: 'neutral',
|
||||
description: 'Refreshable sources are configured but no run is in progress.',
|
||||
description: t('liveArtifact.refresh.statusReadyDescription'),
|
||||
};
|
||||
case 'never':
|
||||
default:
|
||||
return {
|
||||
label: 'Not refreshable',
|
||||
label: t('liveArtifact.refresh.statusNever'),
|
||||
tone: 'warning',
|
||||
description: 'This live artifact has no refresh source yet.',
|
||||
description: t('liveArtifact.refresh.statusNeverDescription'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function describeEventPhase(
|
||||
event: LiveArtifactRefreshEvent,
|
||||
t: TranslateFn,
|
||||
): { label: string; tone: 'running' | 'success' | 'error' } {
|
||||
if (event.phase === 'started') return { label: 'Started', tone: 'running' };
|
||||
if (event.phase === 'succeeded') return { label: 'Succeeded', tone: 'success' };
|
||||
return { label: 'Failed', tone: 'error' };
|
||||
if (event.phase === 'started')
|
||||
return { label: t('liveArtifact.refresh.eventStarted'), tone: 'running' };
|
||||
if (event.phase === 'succeeded')
|
||||
return { label: t('liveArtifact.refresh.eventSucceeded'), tone: 'success' };
|
||||
return { label: t('liveArtifact.refresh.eventFailed'), tone: 'error' };
|
||||
}
|
||||
|
||||
function describePersistedStatus(
|
||||
status: LiveArtifactRefreshLogEntry['status'],
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
return t('liveArtifact.refresh.persistedStatusSucceeded');
|
||||
case 'running':
|
||||
return t('liveArtifact.refresh.persistedStatusRunning');
|
||||
case 'failed':
|
||||
return t('liveArtifact.refresh.persistedStatusFailed');
|
||||
case 'cancelled':
|
||||
return t('liveArtifact.refresh.persistedStatusCancelled');
|
||||
case 'skipped':
|
||||
return t('liveArtifact.refresh.persistedStatusSkipped');
|
||||
default: {
|
||||
const exhaustive: never = status;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function LiveArtifactRefreshHistoryPanel({
|
||||
@@ -1136,6 +1188,8 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
sessionEvents: LiveArtifactRefreshEvent[];
|
||||
persistedEvents?: LiveArtifactRefreshLogEntry[];
|
||||
}) {
|
||||
const t = useT();
|
||||
const { locale } = useI18n();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1147,7 +1201,7 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
const status: LiveArtifactRefreshStatus = isRunning
|
||||
? 'running'
|
||||
: liveArtifact?.refreshStatus ?? fallbackRefreshStatus;
|
||||
const descriptor = describeRefreshStatus(status);
|
||||
const descriptor = describeRefreshStatus(status, t);
|
||||
const lastRefreshedAt = liveArtifact?.lastRefreshedAt ?? fallbackLastRefreshedAt;
|
||||
const createdAt = liveArtifact?.createdAt;
|
||||
const updatedAt = liveArtifact?.updatedAt;
|
||||
@@ -1176,11 +1230,13 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
</div>
|
||||
<div className="live-artifact-refresh-hero-meta">
|
||||
<div className="live-artifact-refresh-hero-metric">
|
||||
<span className="live-artifact-refresh-label">Last refreshed</span>
|
||||
<span className="live-artifact-refresh-label">
|
||||
{t('liveArtifact.refresh.heroLastRefreshedLabel')}
|
||||
</span>
|
||||
{lastRefreshedAt ? (
|
||||
<>
|
||||
<span className="live-artifact-refresh-value">
|
||||
{formatRelativeTime(lastRefreshedAt, now) ?? '—'}
|
||||
{formatRelativeTime(lastRefreshedAt, now, locale, t) ?? '—'}
|
||||
</span>
|
||||
<span
|
||||
className="live-artifact-refresh-sub"
|
||||
@@ -1190,7 +1246,9 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="live-artifact-refresh-value muted">Never</span>
|
||||
<span className="live-artifact-refresh-value muted">
|
||||
{t('liveArtifact.refresh.heroLastRefreshedNever')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1198,29 +1256,33 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
|
||||
<section className="live-artifact-refresh-facts">
|
||||
<LiveArtifactRefreshFact
|
||||
label="Created"
|
||||
label={t('liveArtifact.refresh.factCreated')}
|
||||
iso={createdAt}
|
||||
emptyLabel="Unknown"
|
||||
emptyLabel={t('liveArtifact.refresh.factUnknown')}
|
||||
now={now}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
<LiveArtifactRefreshFact
|
||||
label="Last updated"
|
||||
label={t('liveArtifact.refresh.factLastUpdated')}
|
||||
iso={updatedAt}
|
||||
emptyLabel="Unknown"
|
||||
emptyLabel={t('liveArtifact.refresh.factUnknown')}
|
||||
now={now}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="live-artifact-refresh-section">
|
||||
<header className="live-artifact-refresh-section-header">
|
||||
<h4>Persisted refresh history</h4>
|
||||
<h4>{t('liveArtifact.refresh.persistedTitle')}</h4>
|
||||
<span className="live-artifact-refresh-hint">
|
||||
Entries loaded from refreshes.jsonl
|
||||
{t('liveArtifact.refresh.persistedHint')}
|
||||
</span>
|
||||
</header>
|
||||
{reversedPersistedEvents.length === 0 ? (
|
||||
<div className="live-artifact-refresh-empty">
|
||||
No persisted refresh history yet.
|
||||
{t('liveArtifact.refresh.persistedEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="live-artifact-refresh-timeline">
|
||||
@@ -1239,11 +1301,12 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
<div className="live-artifact-refresh-event-body">
|
||||
<div className="live-artifact-refresh-event-row">
|
||||
<span className={`live-artifact-badge refresh-status tone-${tone}`}>
|
||||
{event.status}
|
||||
{describePersistedStatus(event.status, t)}
|
||||
</span>
|
||||
<strong>{event.step}</strong>
|
||||
<span className="live-artifact-refresh-event-time">
|
||||
{formatRelativeTime(event.startedAt, now) ?? 'just now'}
|
||||
{formatRelativeTime(event.startedAt, now, locale, t)
|
||||
?? t('liveArtifact.refresh.justNow')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="live-artifact-refresh-event-meta">
|
||||
@@ -1261,21 +1324,21 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
|
||||
<section className="live-artifact-refresh-section">
|
||||
<header className="live-artifact-refresh-section-header">
|
||||
<h4>Session activity</h4>
|
||||
<h4>{t('liveArtifact.refresh.sessionTitle')}</h4>
|
||||
<span className="live-artifact-refresh-hint">
|
||||
Events observed while this tab is open
|
||||
{t('liveArtifact.refresh.sessionHint')}
|
||||
</span>
|
||||
</header>
|
||||
{reversedEvents.length === 0 ? (
|
||||
<div className="live-artifact-refresh-empty">
|
||||
No refresh activity yet in this session. Trigger
|
||||
{' '}<em>Refresh</em>{' '}to record a timeline, or wait for automated runs.
|
||||
{t('liveArtifact.refresh.timelineEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="live-artifact-refresh-timeline">
|
||||
{reversedEvents.map((event) => {
|
||||
const phase = describeEventPhase(event);
|
||||
const phase = describeEventPhase(event, t);
|
||||
const duration = formatDurationMs(event.durationMs);
|
||||
const refreshedCount = event.refreshedSourceCount ?? 0;
|
||||
return (
|
||||
<li key={event.id} className={`live-artifact-refresh-event tone-${phase.tone}`}>
|
||||
<span className="live-artifact-refresh-event-dot" aria-hidden />
|
||||
@@ -1290,24 +1353,27 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
className="live-artifact-refresh-event-time"
|
||||
title={formatAbsoluteDateTime(event.at) ?? undefined}
|
||||
>
|
||||
{formatRelativeTime(event.at, now) ?? ''}
|
||||
{formatRelativeTime(event.at, now, locale, t) ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="live-artifact-refresh-event-detail">
|
||||
{event.phase === 'succeeded' ? (
|
||||
<span>
|
||||
{`${event.refreshedSourceCount ?? 0} source${
|
||||
(event.refreshedSourceCount ?? 0) === 1 ? '' : 's'
|
||||
} updated`}
|
||||
{t(
|
||||
refreshedCount === 1
|
||||
? 'liveArtifact.refresh.sourcesUpdatedOne'
|
||||
: 'liveArtifact.refresh.sourcesUpdatedMany',
|
||||
{ n: refreshedCount },
|
||||
)}
|
||||
{duration ? ` · ${duration}` : ''}
|
||||
</span>
|
||||
) : event.phase === 'failed' ? (
|
||||
<span>
|
||||
{event.error ?? 'Refresh failed.'}
|
||||
{event.error ?? t('liveArtifact.refresh.genericFailure')}
|
||||
{duration ? ` · ${duration}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span>Refresh started…</span>
|
||||
<span>{t('liveArtifact.refresh.eventStartedDetail')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1321,19 +1387,19 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
{documentSource ? (
|
||||
<section className="live-artifact-refresh-section">
|
||||
<header className="live-artifact-refresh-section-header">
|
||||
<h4>Document source</h4>
|
||||
<h4>{t('liveArtifact.refresh.docSourceTitle')}</h4>
|
||||
<span className="live-artifact-refresh-hint">
|
||||
Source configured
|
||||
{t('liveArtifact.refresh.docSourceHint')}
|
||||
</span>
|
||||
</header>
|
||||
<dl className="live-artifact-refresh-kv">
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dt>{t('liveArtifact.refresh.docSourceType')}</dt>
|
||||
<dd>{documentSource.type}</dd>
|
||||
</div>
|
||||
{documentSource.toolName ? (
|
||||
<div>
|
||||
<dt>Tool</dt>
|
||||
<dt>{t('liveArtifact.refresh.docSourceTool')}</dt>
|
||||
<dd>
|
||||
<code>{documentSource.toolName}</code>
|
||||
</dd>
|
||||
@@ -1341,7 +1407,7 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
) : null}
|
||||
{documentSource.connector ? (
|
||||
<div>
|
||||
<dt>Connector</dt>
|
||||
<dt>{t('liveArtifact.refresh.docSourceConnector')}</dt>
|
||||
<dd>
|
||||
{documentSource.connector.accountLabel ??
|
||||
documentSource.connector.connectorId}
|
||||
@@ -1354,9 +1420,9 @@ export function LiveArtifactRefreshHistoryPanel({
|
||||
|
||||
{rawDebugPayload != null ? (
|
||||
<details className="live-artifact-refresh-raw">
|
||||
<summary>Advanced debug metadata</summary>
|
||||
<summary>{t('liveArtifact.refresh.debugSummary')}</summary>
|
||||
<p className="live-artifact-refresh-raw-note">
|
||||
May include connector IDs, file names, source metadata, and internal artifact paths.
|
||||
{t('liveArtifact.refresh.debugNote')}
|
||||
</p>
|
||||
<pre className="viewer-source">{JSON.stringify(rawDebugPayload, null, 2)}</pre>
|
||||
</details>
|
||||
@@ -1372,6 +1438,8 @@ function LiveArtifactRefreshFact({
|
||||
helper,
|
||||
emptyLabel,
|
||||
now,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
label: string;
|
||||
iso?: string;
|
||||
@@ -1379,8 +1447,10 @@ function LiveArtifactRefreshFact({
|
||||
helper?: string;
|
||||
emptyLabel?: string;
|
||||
now?: number;
|
||||
locale?: Locale;
|
||||
t?: TranslateFn;
|
||||
}) {
|
||||
const relative = iso !== undefined ? formatRelativeTime(iso, now) : null;
|
||||
const relative = iso !== undefined ? formatRelativeTime(iso, now, locale, t) : null;
|
||||
const absolute = iso !== undefined ? formatAbsoluteDateTime(iso) : null;
|
||||
const resolved = value ?? relative ?? emptyLabel ?? '—';
|
||||
const sub = helper ?? (iso !== undefined ? absolute ?? '' : '');
|
||||
|
||||
@@ -907,6 +907,49 @@ export const en: Dict = {
|
||||
'liveArtifact.refresh.statusReady': 'Ready to refresh',
|
||||
'liveArtifact.refresh.statusSucceeded': 'Up to date',
|
||||
'liveArtifact.refresh.statusFailed': 'Refresh failed',
|
||||
'liveArtifact.refresh.statusRunning': 'Refreshing',
|
||||
'liveArtifact.refresh.statusRunningDescription':
|
||||
'A refresh run is currently in progress.',
|
||||
'liveArtifact.refresh.statusSucceededDescription':
|
||||
'The last refresh finished successfully.',
|
||||
'liveArtifact.refresh.statusFailedDescription':
|
||||
'The last refresh attempt did not complete successfully.',
|
||||
'liveArtifact.refresh.statusReadyDescription':
|
||||
'Refreshable sources are configured but no run is in progress.',
|
||||
'liveArtifact.refresh.statusNeverDescription':
|
||||
'This live artifact has no refresh source yet.',
|
||||
'liveArtifact.refresh.eventStarted': 'Started',
|
||||
'liveArtifact.refresh.eventSucceeded': 'Succeeded',
|
||||
'liveArtifact.refresh.eventFailed': 'Failed',
|
||||
'liveArtifact.refresh.eventStartedDetail': 'Refresh started…',
|
||||
'liveArtifact.refresh.sourcesUpdatedOne': '{n} source updated',
|
||||
'liveArtifact.refresh.sourcesUpdatedMany': '{n} sources updated',
|
||||
'liveArtifact.refresh.timelineEmpty':
|
||||
'No refresh activity yet in this session. Trigger Refresh to record a timeline, or wait for automated runs.',
|
||||
'liveArtifact.refresh.heroLastRefreshedLabel': 'Last refreshed',
|
||||
'liveArtifact.refresh.heroLastRefreshedNever': 'Never',
|
||||
'liveArtifact.refresh.justNow': 'just now',
|
||||
'liveArtifact.refresh.factCreated': 'Created',
|
||||
'liveArtifact.refresh.factLastUpdated': 'Last updated',
|
||||
'liveArtifact.refresh.factUnknown': 'Unknown',
|
||||
'liveArtifact.refresh.persistedTitle': 'Persisted refresh history',
|
||||
'liveArtifact.refresh.persistedHint': 'Entries loaded from refreshes.jsonl',
|
||||
'liveArtifact.refresh.persistedEmpty': 'No persisted refresh history yet.',
|
||||
'liveArtifact.refresh.persistedStatusSucceeded': 'succeeded',
|
||||
'liveArtifact.refresh.persistedStatusRunning': 'running',
|
||||
'liveArtifact.refresh.persistedStatusFailed': 'failed',
|
||||
'liveArtifact.refresh.persistedStatusCancelled': 'cancelled',
|
||||
'liveArtifact.refresh.persistedStatusSkipped': 'skipped',
|
||||
'liveArtifact.refresh.sessionTitle': 'Session activity',
|
||||
'liveArtifact.refresh.sessionHint': 'Events observed while this tab is open',
|
||||
'liveArtifact.refresh.docSourceTitle': 'Document source',
|
||||
'liveArtifact.refresh.docSourceHint': 'Source configured',
|
||||
'liveArtifact.refresh.docSourceType': 'Type',
|
||||
'liveArtifact.refresh.docSourceTool': 'Tool',
|
||||
'liveArtifact.refresh.docSourceConnector': 'Connector',
|
||||
'liveArtifact.refresh.debugSummary': 'Advanced debug metadata',
|
||||
'liveArtifact.refresh.debugNote':
|
||||
'May include connector IDs, file names, source metadata, and internal artifact paths.',
|
||||
'liveArtifact.viewer.tabPreview': 'Preview',
|
||||
'liveArtifact.viewer.tabCode': 'Code',
|
||||
'liveArtifact.viewer.tabData': 'Data',
|
||||
|
||||
@@ -894,6 +894,44 @@ export const zhCN: Dict = {
|
||||
'liveArtifact.refresh.statusReady': '可刷新',
|
||||
'liveArtifact.refresh.statusSucceeded': '已是最新',
|
||||
'liveArtifact.refresh.statusFailed': '刷新失败',
|
||||
'liveArtifact.refresh.statusRunning': '正在刷新',
|
||||
'liveArtifact.refresh.statusRunningDescription': '刷新任务正在进行中。',
|
||||
'liveArtifact.refresh.statusSucceededDescription': '上次刷新已成功完成。',
|
||||
'liveArtifact.refresh.statusFailedDescription': '上次刷新未能成功完成。',
|
||||
'liveArtifact.refresh.statusReadyDescription':
|
||||
'已配置可刷新的数据源,但暂无正在进行的任务。',
|
||||
'liveArtifact.refresh.statusNeverDescription': '该实时产物尚未配置刷新数据源。',
|
||||
'liveArtifact.refresh.eventStarted': '已开始',
|
||||
'liveArtifact.refresh.eventSucceeded': '已成功',
|
||||
'liveArtifact.refresh.eventFailed': '失败',
|
||||
'liveArtifact.refresh.eventStartedDetail': '刷新已开始…',
|
||||
'liveArtifact.refresh.sourcesUpdatedOne': '已更新 {n} 个数据源',
|
||||
'liveArtifact.refresh.sourcesUpdatedMany': '已更新 {n} 个数据源',
|
||||
'liveArtifact.refresh.timelineEmpty':
|
||||
'本次会话还没有刷新记录。点击「刷新」即可记录时间线,或等待自动任务完成。',
|
||||
'liveArtifact.refresh.heroLastRefreshedLabel': '上次刷新',
|
||||
'liveArtifact.refresh.heroLastRefreshedNever': '从未',
|
||||
'liveArtifact.refresh.justNow': '刚刚',
|
||||
'liveArtifact.refresh.factCreated': '创建时间',
|
||||
'liveArtifact.refresh.factLastUpdated': '最后更新',
|
||||
'liveArtifact.refresh.factUnknown': '未知',
|
||||
'liveArtifact.refresh.persistedTitle': '持久化刷新记录',
|
||||
'liveArtifact.refresh.persistedHint': '来自 refreshes.jsonl 的条目',
|
||||
'liveArtifact.refresh.persistedEmpty': '尚无持久化的刷新记录。',
|
||||
'liveArtifact.refresh.persistedStatusSucceeded': '成功',
|
||||
'liveArtifact.refresh.persistedStatusRunning': '进行中',
|
||||
'liveArtifact.refresh.persistedStatusFailed': '失败',
|
||||
'liveArtifact.refresh.persistedStatusCancelled': '已取消',
|
||||
'liveArtifact.refresh.persistedStatusSkipped': '已跳过',
|
||||
'liveArtifact.refresh.sessionTitle': '会话活动',
|
||||
'liveArtifact.refresh.sessionHint': '本标签页打开期间观察到的事件',
|
||||
'liveArtifact.refresh.docSourceTitle': '文档来源',
|
||||
'liveArtifact.refresh.docSourceHint': '已配置的数据源',
|
||||
'liveArtifact.refresh.docSourceType': '类型',
|
||||
'liveArtifact.refresh.docSourceTool': '工具',
|
||||
'liveArtifact.refresh.docSourceConnector': '连接器',
|
||||
'liveArtifact.refresh.debugSummary': '高级调试元数据',
|
||||
'liveArtifact.refresh.debugNote': '可能包含连接器 ID、文件名、来源元数据以及内部 artifact 路径。',
|
||||
'fileViewer.deployToVercel': '部署到 Vercel',
|
||||
'fileViewer.redeployToVercel': '重新部署',
|
||||
'fileViewer.deployingToVercel': '正在部署到 Vercel…',
|
||||
|
||||
@@ -1163,6 +1163,42 @@ export interface Dict {
|
||||
'liveArtifact.refresh.statusReady': string;
|
||||
'liveArtifact.refresh.statusSucceeded': string;
|
||||
'liveArtifact.refresh.statusFailed': string;
|
||||
'liveArtifact.refresh.statusRunning': string;
|
||||
'liveArtifact.refresh.statusRunningDescription': string;
|
||||
'liveArtifact.refresh.statusSucceededDescription': string;
|
||||
'liveArtifact.refresh.statusFailedDescription': string;
|
||||
'liveArtifact.refresh.statusReadyDescription': string;
|
||||
'liveArtifact.refresh.statusNeverDescription': string;
|
||||
'liveArtifact.refresh.eventStarted': string;
|
||||
'liveArtifact.refresh.eventSucceeded': string;
|
||||
'liveArtifact.refresh.eventFailed': string;
|
||||
'liveArtifact.refresh.eventStartedDetail': string;
|
||||
'liveArtifact.refresh.sourcesUpdatedOne': string;
|
||||
'liveArtifact.refresh.sourcesUpdatedMany': string;
|
||||
'liveArtifact.refresh.timelineEmpty': string;
|
||||
'liveArtifact.refresh.heroLastRefreshedLabel': string;
|
||||
'liveArtifact.refresh.heroLastRefreshedNever': string;
|
||||
'liveArtifact.refresh.justNow': string;
|
||||
'liveArtifact.refresh.factCreated': string;
|
||||
'liveArtifact.refresh.factLastUpdated': string;
|
||||
'liveArtifact.refresh.factUnknown': string;
|
||||
'liveArtifact.refresh.persistedTitle': string;
|
||||
'liveArtifact.refresh.persistedHint': string;
|
||||
'liveArtifact.refresh.persistedEmpty': string;
|
||||
'liveArtifact.refresh.persistedStatusSucceeded': string;
|
||||
'liveArtifact.refresh.persistedStatusRunning': string;
|
||||
'liveArtifact.refresh.persistedStatusFailed': string;
|
||||
'liveArtifact.refresh.persistedStatusCancelled': string;
|
||||
'liveArtifact.refresh.persistedStatusSkipped': string;
|
||||
'liveArtifact.refresh.sessionTitle': string;
|
||||
'liveArtifact.refresh.sessionHint': string;
|
||||
'liveArtifact.refresh.docSourceTitle': string;
|
||||
'liveArtifact.refresh.docSourceHint': string;
|
||||
'liveArtifact.refresh.docSourceType': string;
|
||||
'liveArtifact.refresh.docSourceTool': string;
|
||||
'liveArtifact.refresh.docSourceConnector': string;
|
||||
'liveArtifact.refresh.debugSummary': string;
|
||||
'liveArtifact.refresh.debugNote': string;
|
||||
'fileViewer.deployProviderLabel': string;
|
||||
'fileViewer.vercelProvider': string;
|
||||
'fileViewer.cloudflarePagesProvider': string;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../../src/components/FileViewer';
|
||||
import type { InspectOverrideMap } from '../../src/components/FileViewer';
|
||||
import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -1891,4 +1892,124 @@ describe('LiveArtifactRefreshHistoryPanel', () => {
|
||||
expect(markup).toContain('2 sources updated');
|
||||
expect(markup).toContain('3.8s');
|
||||
});
|
||||
|
||||
// Lefarcen review on PR #1300: the existing renderToStaticMarkup
|
||||
// assertions above can't prove that the panel actually routes its
|
||||
// strings through i18n, because the no-provider fallback returns
|
||||
// English no matter what locale the rest of the app is set to. This
|
||||
// test wraps the panel in `I18nProvider initial="zh-CN"` and pins
|
||||
// the Chinese rendering of the strings issue #1254 was filed for:
|
||||
// the badge descriptor, the hero label + empty state, the session
|
||||
// section header + hint, the empty-timeline copy, the persisted
|
||||
// section + its empty copy, started / succeeded event labels, the
|
||||
// pluralised source-count line, the document-source labels, and the
|
||||
// advanced debug summary. If a future change drops `t()` off any
|
||||
// of those callsites, this test catches it before the user sees
|
||||
// the mixed-language regression.
|
||||
it('renders Chinese strings end-to-end when wrapped in I18nProvider initial="zh-CN"', () => {
|
||||
const now = Date.now();
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider initial="zh-CN">
|
||||
<LiveArtifactRefreshHistoryPanel
|
||||
liveArtifact={baseLiveArtifact({
|
||||
refreshStatus: 'succeeded',
|
||||
// Real lastRefreshedAt + non-empty session events so the
|
||||
// relative-time path also runs under zh-CN; the lefarcen
|
||||
// P1 review specifically called out that the formerly
|
||||
// hardcoded `Xs ago` / `Xm ago` strings would still leak
|
||||
// English under a Chinese UI without this.
|
||||
lastRefreshedAt: new Date(now - 45_000).toISOString(),
|
||||
document: {
|
||||
format: 'html_template_v1',
|
||||
templatePath: 'template.html',
|
||||
generatedPreviewPath: 'index.html',
|
||||
dataPath: 'data.json',
|
||||
dataJson: { title: 'Launch Metrics' },
|
||||
sourceJson: {
|
||||
type: 'connector_tool',
|
||||
toolName: 'design-files.list',
|
||||
input: {},
|
||||
refreshPermission: 'none',
|
||||
connector: {
|
||||
connectorId: 'figma',
|
||||
toolName: 'design-files.list',
|
||||
accountLabel: 'figma:acct-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
fallbackRefreshStatus="succeeded"
|
||||
isRunning={false}
|
||||
sessionEvents={[
|
||||
{ id: 1, phase: 'started', at: now - 5_000 },
|
||||
{
|
||||
id: 2,
|
||||
phase: 'succeeded',
|
||||
at: now - 1_200,
|
||||
durationMs: 3_800,
|
||||
refreshedSourceCount: 1,
|
||||
},
|
||||
]}
|
||||
persistedEvents={[]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Hero
|
||||
expect(markup).toContain('上次刷新');
|
||||
// Session activity section
|
||||
expect(markup).toContain('会话活动');
|
||||
expect(markup).toContain('本标签页打开期间观察到的事件');
|
||||
// Event labels + pluralised source count for n === 1
|
||||
expect(markup).toContain('已开始');
|
||||
expect(markup).toContain('已成功');
|
||||
expect(markup).toContain('已更新 1 个数据源');
|
||||
// Persisted history section + empty copy
|
||||
expect(markup).toContain('持久化刷新记录');
|
||||
expect(markup).toContain('尚无持久化的刷新记录。');
|
||||
// Document source section
|
||||
expect(markup).toContain('文档来源');
|
||||
expect(markup).toContain('已配置的数据源');
|
||||
expect(markup).toContain('类型');
|
||||
expect(markup).toContain('工具');
|
||||
expect(markup).toContain('连接器');
|
||||
// Advanced debug metadata
|
||||
expect(markup).toContain('高级调试元数据');
|
||||
// English label that previously leaked through must NOT appear
|
||||
// (mixed-language is exactly the regression issue #1254 filed for).
|
||||
expect(markup).not.toContain('Last refreshed');
|
||||
expect(markup).not.toContain('Session activity');
|
||||
expect(markup).not.toContain('Persisted refresh history');
|
||||
expect(markup).not.toContain('Document source');
|
||||
expect(markup).not.toContain('Advanced debug metadata');
|
||||
// Relative-time output must be Chinese, not English. The lefarcen
|
||||
// P1 review pointed out that formatRelativeTime was hardcoding
|
||||
// English units (`Xs ago`), so a 45s-old hero metric would still
|
||||
// read `45s ago` even with every label translated. Assert against
|
||||
// the Chinese past-tense suffix `前` and rule out the English
|
||||
// suffixes the legacy function emitted.
|
||||
expect(markup).toContain('前');
|
||||
expect(markup).not.toContain(' ago');
|
||||
expect(markup).not.toContain('from now');
|
||||
expect(markup).not.toMatch(/\b\d+s ago\b/);
|
||||
expect(markup).not.toMatch(/\b\d+m ago\b/);
|
||||
});
|
||||
|
||||
it('renders the zh-CN empty hero ("从未") when lastRefreshedAt is missing', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider initial="zh-CN">
|
||||
<LiveArtifactRefreshHistoryPanel
|
||||
liveArtifact={baseLiveArtifact({ refreshStatus: 'never', lastRefreshedAt: undefined })}
|
||||
fallbackRefreshStatus="never"
|
||||
isRunning={false}
|
||||
sessionEvents={[]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('上次刷新');
|
||||
expect(markup).toContain('从未');
|
||||
expect(markup).not.toContain('Last refreshed');
|
||||
expect(markup).not.toContain('>Never<');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 15 MiB |
@@ -45,7 +45,7 @@ Each tag has a single mechanical rule. Adding new tags requires the rule to be e
|
||||
| `non-ascii-slug` | A design-system root touched by the PR has a slug that fails `/^[a-z0-9-]+$/` | files + lane.DESIGN_DIR |
|
||||
| `maintainer-edits-disabled` | `maintainerCanModify === false` | gh.maintainerCanModify |
|
||||
| `org-member` | PR author's GitHub login appears in `gh api orgs/<repo-owner>/members` | gh REST orgs members list |
|
||||
| `unresolved-changes-requested` | Any reviewer's latest review has `state === "CHANGES_REQUESTED"` | gh.latestReviews[].state |
|
||||
| `unresolved-changes-requested` | A reviewer's latest review has `state === "CHANGES_REQUESTED"` (primary); falls back to `reviewDecision === "CHANGES_REQUESTED"` at PR level when no per-reviewer CR survives the latest-per-author reduction (e.g. reviewer's CR followed by COMMENTED, or CR outside the `reviews(last: 30)` window) | gh.latestReviews[].state · gh.reviewDecision (fallback) |
|
||||
| `stale-approval` | Any `APPROVED` review's `commit.oid` differs from current `headRefOid` | gh.latestReviews[].commit.oid + gh.headRefOid |
|
||||
| `awaiting-author-response-24h` | Latest human-reviewer signal time is newer than the latest author signal time and is ≥ 24h ago | latestReviews + comments + commits |
|
||||
| `awaiting-reviewer-response-24h` | Latest author signal time is newer than the latest human-reviewer signal time, ≥ 24h ago, and at least one human-reviewer signal exists | latestReviews + comments + commits |
|
||||
|
||||
@@ -116,12 +116,30 @@ function tagUnresolvedChangesRequested(facts: PrFacts): Tag | null {
|
||||
.filter((r) => r.state === "CHANGES_REQUESTED" && r.author?.login)
|
||||
.map((r) => r.author?.login)
|
||||
.filter((login): login is string => typeof login === "string");
|
||||
if (reviewers.length === 0) return null;
|
||||
return {
|
||||
name: "unresolved-changes-requested",
|
||||
reason: `latestReviews carries CHANGES_REQUESTED from: ${[...new Set(reviewers)].join(", ")}`,
|
||||
source: "gh.latestReviews[].state",
|
||||
};
|
||||
if (reviewers.length > 0) {
|
||||
return {
|
||||
name: "unresolved-changes-requested",
|
||||
reason: `latestReviews carries CHANGES_REQUESTED from: ${[...new Set(reviewers)].join(", ")}`,
|
||||
source: "gh.latestReviews[].state",
|
||||
};
|
||||
}
|
||||
// The reducer-side path misses cases where GitHub's reviewDecision still
|
||||
// reports CHANGES_REQUESTED but the latest-per-author reduction of fetched
|
||||
// reviews carries none — e.g. the reviewer's CR is followed by COMMENTED
|
||||
// (COMMENTED does not supersede CR in GitHub's decision logic), or the CR
|
||||
// sits outside the `reviews(last: 30)` window. Either way the PR-level
|
||||
// decision is the authoritative signal; fall back to it without asserting
|
||||
// a specific cause. Observed scale on the live queue: 3 of 102 open PRs
|
||||
// (#1101 / #1127 / #1163) hit this gap, so this is a recurring pattern,
|
||||
// not a theoretical edge case.
|
||||
if (facts.reviewDecision === "CHANGES_REQUESTED") {
|
||||
return {
|
||||
name: "unresolved-changes-requested",
|
||||
reason: "reviewDecision=CHANGES_REQUESTED at PR level; no per-reviewer CHANGES_REQUESTED state in latest-per-author reduction of fetched reviews",
|
||||
source: "gh.reviewDecision",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tagStaleApproval(facts: PrFacts): Tag | null {
|
||||
|
||||
158
tools/pr/tests/tags-unresolved-cr.test.ts
Normal file
158
tools/pr/tests/tags-unresolved-cr.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Pins the two emission paths of `unresolved-changes-requested` after the
|
||||
* primary `gh.latestReviews[].state` rule grew a `gh.reviewDecision` fallback.
|
||||
*
|
||||
* Patrol on the live 102-PR queue surfaced three PRs (#1101, #1127, #1163)
|
||||
* where GitHub's PR-level `reviewDecision` was CHANGES_REQUESTED but the
|
||||
* latest-per-author reduction of fetched reviews carried none — the primary
|
||||
* rule alone missed them. The fallback now picks them up, and these cases
|
||||
* make sure a future refactor of either path can't silently regress that
|
||||
* coverage.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { classifyPr } from "../src/tags.js";
|
||||
import type { PrFacts } from "../src/types.js";
|
||||
|
||||
function makeFacts(overrides: Partial<PrFacts> = {}): PrFacts {
|
||||
return {
|
||||
number: 1,
|
||||
author: "alice",
|
||||
title: "test PR",
|
||||
createdAt: "2026-05-10T00:00:00Z",
|
||||
updatedAt: "2026-05-10T00:00:00Z",
|
||||
isDraft: false,
|
||||
reviewDecision: "",
|
||||
mergeStateStatus: "CLEAN",
|
||||
maintainerCanModify: true,
|
||||
isOrgMember: false,
|
||||
headRefOid: "abc1234",
|
||||
assignees: [],
|
||||
labels: [{ name: "size/S" }, { name: "risk/low" }, { name: "type/bugfix" }],
|
||||
filePaths: ["apps/web/src/foo.ts"],
|
||||
reviews: [],
|
||||
comments: [],
|
||||
commits: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_CTX = { titleIndexByAuthor: new Map<string, number[]>() };
|
||||
|
||||
describe("unresolved-changes-requested — primary path", () => {
|
||||
it("fires with gh.latestReviews[].state source when a reviewer's latest review is CHANGES_REQUESTED", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviews: [
|
||||
{
|
||||
author: { login: "bob" },
|
||||
body: "needs changes",
|
||||
state: "CHANGES_REQUESTED",
|
||||
submittedAt: "2026-05-10T01:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
const tag = tags.find((t) => t.name === "unresolved-changes-requested");
|
||||
assert.ok(tag, "unresolved-changes-requested should fire");
|
||||
assert.equal(tag?.source, "gh.latestReviews[].state");
|
||||
assert.match(tag?.reason ?? "", /bob/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolved-changes-requested — fallback path", () => {
|
||||
it("fires with gh.reviewDecision source when reviewDecision is CHANGES_REQUESTED but no per-reviewer CR survives reduction", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
// bob's latest is COMMENTED — GitHub's reviewDecision still reports
|
||||
// CHANGES_REQUESTED (only APPROVED / DISMISSED supersedes per-reviewer
|
||||
// CR), but the per-reviewer rule sees no CR after reduction.
|
||||
reviews: [
|
||||
{
|
||||
author: { login: "bob" },
|
||||
body: "",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2026-05-10T02:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
const tag = tags.find((t) => t.name === "unresolved-changes-requested");
|
||||
assert.ok(tag, "fallback should fire");
|
||||
assert.equal(tag?.source, "gh.reviewDecision");
|
||||
});
|
||||
|
||||
it("fires with gh.reviewDecision source when reviews array is empty and reviewDecision is CHANGES_REQUESTED (e.g. CR outside the reviews(last:30) window)", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviews: [],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
const tag = tags.find((t) => t.name === "unresolved-changes-requested");
|
||||
assert.ok(tag, "fallback should fire on empty reviews");
|
||||
assert.equal(tag?.source, "gh.reviewDecision");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolved-changes-requested — primary wins when both paths qualify", () => {
|
||||
it("emits the per-reviewer-source tag, not the fallback, when both signals are present", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviews: [
|
||||
{
|
||||
author: { login: "bob" },
|
||||
body: "needs changes",
|
||||
state: "CHANGES_REQUESTED",
|
||||
submittedAt: "2026-05-10T01:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
const matches = tags.filter((t) => t.name === "unresolved-changes-requested");
|
||||
assert.equal(matches.length, 1, "tag should be emitted exactly once");
|
||||
assert.equal(matches[0]?.source, "gh.latestReviews[].state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolved-changes-requested — negative cases", () => {
|
||||
it("does not fire when reviewDecision is empty and no per-reviewer CR exists", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "",
|
||||
reviews: [
|
||||
{
|
||||
author: { login: "bob" },
|
||||
body: "looks good",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2026-05-10T01:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
assert.equal(
|
||||
tags.find((t) => t.name === "unresolved-changes-requested"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire when reviewDecision is APPROVED", () => {
|
||||
const facts = makeFacts({
|
||||
reviewDecision: "APPROVED",
|
||||
reviews: [
|
||||
{
|
||||
author: { login: "bob" },
|
||||
body: "lgtm",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-05-10T01:00:00Z",
|
||||
commit: { oid: "abc1234" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const tags = classifyPr(facts, EMPTY_CTX);
|
||||
assert.equal(
|
||||
tags.find((t) => t.name === "unresolved-changes-requested"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user