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 commit 966f9c31), Phase 9 (#1315
commit 89956019), and Phase 10 (#1316 commit 9b72b54) 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:
Nagendhra
2026-05-11 22:56:58 -04:00
22 changed files with 810 additions and 302 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 dentré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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ?? '' : '');

View File

@@ -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',

View File

@@ -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…',

View File

@@ -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;

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 {

View 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,
);
});
});