mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge remote-tracking branch 'upstream/main' into codex/pr2344-resync-20260529
# Conflicts: # apps/daemon/src/project-routes.ts
This commit is contained in:
commit
432d089880
69 changed files with 5189 additions and 132 deletions
|
|
@ -800,7 +800,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-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
|
||||
|
|
@ -817,9 +817,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -726,7 +726,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-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -743,9 +743,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -787,7 +787,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-29" alt="Contribuidores de Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -804,9 +804,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<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-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -733,7 +733,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-29" alt="Contributeurs Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -750,9 +750,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<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-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -723,7 +723,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-29" alt="Open Design コントリビューター" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) ラベルがエントリポイントです。
|
||||
|
|
@ -740,9 +740,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -726,7 +726,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-29" alt="Open Design 컨트리뷰터" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) 레이블이 시작점입니다.
|
||||
|
|
@ -743,9 +743,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -1040,7 +1040,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-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -1057,9 +1057,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -730,7 +730,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-29" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -747,9 +747,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<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-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,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-29" alt="Contributors Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) — хорошая точка входа.
|
||||
|
|
@ -746,9 +746,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -887,7 +887,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-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
|
||||
|
|
@ -904,9 +904,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,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-29" alt="Контриб'ютори Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) — це точка входу.
|
||||
|
|
@ -746,9 +746,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -722,7 +722,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-29" alt="Open Design 贡献者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) 标签起步。
|
||||
|
|
@ -739,9 +739,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -1006,7 +1006,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-29" alt="Open Design 貢獻者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) 標籤起步。
|
||||
|
|
@ -1023,9 +1023,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-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<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-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
inlineRelativeAssets,
|
||||
type InlineAssetReader,
|
||||
} from './inline-assets.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
|
||||
|
||||
|
|
@ -28,6 +29,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
const { insertConversation } = ctx.conversations;
|
||||
const { setTabs } = ctx.projectFiles;
|
||||
const { validateProjectDesignSystemId } = ctx.validation;
|
||||
const rejectSandboxFolderImport = () =>
|
||||
isSandboxModeEnabled(process.env)
|
||||
? 'folder imports are disabled when OD_SANDBOX_MODE is enabled'
|
||||
: null;
|
||||
|
||||
app.post(
|
||||
'/api/import/claude-design',
|
||||
importUpload.single('file'),
|
||||
|
|
@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
|
||||
}
|
||||
const sandboxReason = rejectSandboxFolderImport();
|
||||
if (sandboxReason) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
|
||||
}
|
||||
let trustedPickerImport = false;
|
||||
if (isDesktopAuthGateActive()) {
|
||||
const secret = desktopAuthSecret();
|
||||
|
|
@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
|
||||
}
|
||||
const sandboxReason = rejectSandboxFolderImport();
|
||||
if (sandboxReason) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
|
||||
}
|
||||
let trustedPickerImport = false;
|
||||
if (isDesktopAuthGateActive()) {
|
||||
const secret = desktopAuthSecret();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import path from 'node:path';
|
|||
import { MEDIA_PROVIDERS } from './media-models.js';
|
||||
import { expandHomePrefix } from './home-expansion.js';
|
||||
import { resolveXAIBearer } from './xai-credentials.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
|
||||
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
|
||||
|
|
@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string {
|
|||
}
|
||||
|
||||
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
|
||||
if (isSandboxModeEnabled(process.env)) return null;
|
||||
const home = os.homedir();
|
||||
const codexAuth = await readJsonIfPresent(
|
||||
path.join(home, '.codex', 'auth.json'),
|
||||
|
|
@ -318,6 +320,8 @@ async function resolveXAIOAuthCredential(
|
|||
};
|
||||
}
|
||||
|
||||
if (isSandboxModeEnabled(process.env)) return null;
|
||||
|
||||
// 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json
|
||||
// when the user ran `hermes auth add xai-oauth`. A user who has already authorized
|
||||
// Hermes doesn't have to run a second OAuth dance inside OD.
|
||||
|
|
|
|||
23
apps/daemon/src/project-root.ts
Normal file
23
apps/daemon/src/project-root.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'node:path';
|
||||
|
||||
export function resolveProjectRoot(moduleDir: string): string {
|
||||
const base = path.basename(moduleDir);
|
||||
const daemonDir =
|
||||
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
|
||||
return path.resolve(daemonDir, '../..');
|
||||
}
|
||||
|
||||
export function resolveProjectRootFromNestedModule(moduleDir: string): string {
|
||||
let current = path.resolve(moduleDir);
|
||||
while (true) {
|
||||
const base = path.basename(current);
|
||||
if (base === 'dist' || base === 'src') {
|
||||
return resolveProjectRoot(current);
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return resolveProjectRoot(moduleDir);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Express } from 'express';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
defaultScenarioPluginIdForProjectMetadata,
|
||||
type PluginManifest,
|
||||
|
|
@ -171,6 +172,25 @@ function buildSrcdocTransportShell(): string {
|
|||
</html>`;
|
||||
}
|
||||
|
||||
function projectDetailResolvedDir(
|
||||
projectsRoot: string,
|
||||
project: any,
|
||||
resolveProjectDir: (
|
||||
projectsRoot: string,
|
||||
projectId: string,
|
||||
metadata?: unknown,
|
||||
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
||||
) => string,
|
||||
): string {
|
||||
const baseDir = typeof project?.metadata?.baseDir === 'string'
|
||||
? path.normalize(project.metadata.baseDir)
|
||||
: null;
|
||||
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
|
||||
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
||||
allowUnavailableSandboxImportedProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
|
||||
(function(){
|
||||
if (window.__odUrlScrollBridge) return;
|
||||
|
|
@ -569,7 +589,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
const project = getProject(db, req.params.id);
|
||||
if (!project)
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
||||
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
||||
/** @type {import('@open-design/contracts').ProjectResponse} */
|
||||
const body = { project, resolvedDir };
|
||||
res.json(body);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
isPublicationGuardedArtifactKind,
|
||||
} from './artifact-publication-guard.js';
|
||||
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
||||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||
|
|
@ -40,13 +41,42 @@ export function projectDir(projectsRoot, projectId) {
|
|||
return path.join(projectsRoot, projectId);
|
||||
}
|
||||
|
||||
export class SandboxImportedProjectError extends Error {
|
||||
code = 'SANDBOX_IMPORTED_PROJECT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Imported-folder projects are not available in OD_SANDBOX_MODE until their files are mirrored into the managed project directory.',
|
||||
);
|
||||
this.name = 'SandboxImportedProjectError';
|
||||
}
|
||||
}
|
||||
|
||||
function hasExternalProjectRoot(metadata?) {
|
||||
if (typeof metadata?.baseDir !== 'string') return false;
|
||||
return path.isAbsolute(path.normalize(metadata.baseDir));
|
||||
}
|
||||
|
||||
export function assertSandboxProjectRootAvailable(metadata?) {
|
||||
if (isSandboxModeEnabled(process.env) && hasExternalProjectRoot(metadata)) {
|
||||
throw new SandboxImportedProjectError();
|
||||
}
|
||||
}
|
||||
|
||||
function usesExternalProjectRoot(metadata?) {
|
||||
if (isSandboxModeEnabled(process.env)) return false;
|
||||
return hasExternalProjectRoot(metadata);
|
||||
}
|
||||
|
||||
// Returns the folder a project's files live in. For git-linked projects
|
||||
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
|
||||
// back to the standard computed path under projectsRoot.
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
||||
if (typeof metadata?.baseDir === 'string') {
|
||||
const p = path.normalize(metadata.baseDir);
|
||||
if (path.isAbsolute(p)) return p;
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
|
||||
if (!opts.allowUnavailableSandboxImportedProject) {
|
||||
assertSandboxProjectRootAvailable(metadata);
|
||||
}
|
||||
if (usesExternalProjectRoot(metadata)) {
|
||||
return path.normalize(metadata.baseDir);
|
||||
}
|
||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||
return path.join(projectsRoot, projectId);
|
||||
|
|
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
|||
export async function ensureProject(projectsRoot, projectId, metadata?) {
|
||||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||
// Git-linked folders already exist; skip mkdir to avoid side-effects.
|
||||
if (typeof metadata?.baseDir !== 'string') {
|
||||
if (!usesExternalProjectRoot(metadata)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
|
|
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
|
|||
const out = [];
|
||||
// Skip build/install dirs for linked folders so node_modules doesn't stall
|
||||
// the walk on large repos.
|
||||
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
|
||||
const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
|
||||
await collectFiles(dir, '', out, skipDirs, dir);
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
|
||||
import { resolveProjectRelativePath } from '../home-expansion.js';
|
||||
import { expandConfiguredEnv } from './paths.js';
|
||||
import { resolveAmrOpenCodeExecutable } from './executables.js';
|
||||
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
|
||||
import { resolveProjectRootFromNestedModule } from '../project-root.js';
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
type SandboxRuntimeConfig,
|
||||
} from '../sandbox-mode.js';
|
||||
|
||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||
|
||||
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
);
|
||||
|
||||
// Build the env passed to spawn() for a given agent adapter.
|
||||
//
|
||||
// The claude adapter strips ANTHROPIC_API_KEY so Claude Code's own auth
|
||||
|
|
@ -39,6 +52,7 @@ export function spawnEnvForAgent(
|
|||
configuredEnv: unknown = {},
|
||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||
): NodeJS.ProcessEnv {
|
||||
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
||||
const env = mergeProxyAwareEnv(
|
||||
process.platform,
|
||||
systemProxyEnv,
|
||||
|
|
@ -58,20 +72,41 @@ export function spawnEnvForAgent(
|
|||
const opencodeBin = resolveAmrOpenCodeExecutable(env);
|
||||
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
|
||||
}
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'codex') {
|
||||
stripUnlessCustomBaseUrl(env, 'OPENAI_BASE_URL', [
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
]);
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
|
||||
function sandboxRuntimeConfigForBaseEnv(
|
||||
baseEnv: RuntimeEnvMap,
|
||||
): SandboxRuntimeConfig | null {
|
||||
if (!isSandboxModeEnabled(baseEnv)) return null;
|
||||
const dataDir = baseEnv.OD_DATA_DIR?.trim();
|
||||
if (!dataDir) return null;
|
||||
const resolvedDataDir = resolveProjectRelativePath(
|
||||
dataDir,
|
||||
RUNTIME_MODULE_PROJECT_ROOT,
|
||||
);
|
||||
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
|
||||
}
|
||||
|
||||
function reapplySandboxRuntimeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
sandboxRuntime: SandboxRuntimeConfig | null,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!sandboxRuntime) return env;
|
||||
return applySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
|
||||
// Remove `secretKeys` from `env` unless `baseUrlKey` is set to a non-empty
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
|||
import { delimiter } from 'node:path';
|
||||
import path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { wellKnownUserToolchainBins } from '@open-design/platform';
|
||||
import { resolveSandboxRuntimeConfigFromEnv } from '../sandbox-mode.js';
|
||||
import { expandHomePath } from './paths.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
const RUNTIME_PROJECT_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../..',
|
||||
);
|
||||
|
||||
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
|
||||
['amr', 'VELA_BIN'],
|
||||
['aider', 'AIDER_BIN'],
|
||||
|
|
@ -35,7 +42,12 @@ let cachedToolchainDirs: string[] | null = null;
|
|||
let cachedToolchainDirsAt = 0;
|
||||
|
||||
function userToolchainDirs() {
|
||||
const homeOverride = process.env.OD_AGENT_HOME;
|
||||
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
|
||||
process.env,
|
||||
RUNTIME_PROJECT_ROOT,
|
||||
);
|
||||
const homeOverride =
|
||||
sandboxRuntime?.roots.agentHomeDir ?? process.env.OD_AGENT_HOME;
|
||||
const home = homeOverride || homedir();
|
||||
const now = Date.now();
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfigFromEnv,
|
||||
sandboxAgentProfilesConfigPath,
|
||||
} from '../sandbox-mode.js';
|
||||
import { DEFAULT_MODEL_OPTION, sanitizeCustomModel } from './models.js';
|
||||
import type {
|
||||
RuntimeAgentDef,
|
||||
|
|
@ -9,10 +15,44 @@ import type {
|
|||
RuntimeModelOption,
|
||||
} from './types.js';
|
||||
|
||||
function localAgentProfilesFile(): string {
|
||||
const RUNTIME_PROJECT_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../..',
|
||||
);
|
||||
|
||||
function isInsideDir(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function localAgentProfilesFile(): string | null {
|
||||
const explicit = process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
if (typeof explicit === 'string' && explicit.trim()) {
|
||||
return explicit.trim();
|
||||
const explicitPath =
|
||||
typeof explicit === 'string' && explicit.trim()
|
||||
? path.resolve(explicit.trim())
|
||||
: null;
|
||||
|
||||
if (isSandboxModeEnabled(process.env)) {
|
||||
if (!process.env.OD_DATA_DIR?.trim()) return null;
|
||||
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
|
||||
process.env,
|
||||
RUNTIME_PROJECT_ROOT,
|
||||
);
|
||||
if (!sandboxRuntime?.enabled) return null;
|
||||
if (
|
||||
explicitPath &&
|
||||
isInsideDir(sandboxRuntime.roots.agentHomeDir, explicitPath)
|
||||
) {
|
||||
return explicitPath;
|
||||
}
|
||||
return sandboxAgentProfilesConfigPath(sandboxRuntime);
|
||||
}
|
||||
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
return path.join(homedir(), '.open-design', 'agents.local.json');
|
||||
}
|
||||
|
|
@ -152,9 +192,11 @@ function createLocalAgentDef(
|
|||
export function readLocalAgentProfileDefs(
|
||||
baseDefs: RuntimeAgentDef[],
|
||||
): RuntimeAgentDef[] {
|
||||
const profilesFile = localAgentProfilesFile();
|
||||
if (profilesFile == null) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(localAgentProfilesFile(), 'utf8'));
|
||||
parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
134
apps/daemon/src/sandbox-mode.ts
Normal file
134
apps/daemon/src/sandbox-mode.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { resolveProjectRelativePath } from './home-expansion.js';
|
||||
|
||||
export const SANDBOX_MODE_ENV = 'OD_SANDBOX_MODE';
|
||||
|
||||
export interface SandboxRuntimeRoots {
|
||||
agentHomeDir: string;
|
||||
cacheDir: string;
|
||||
configDir: string;
|
||||
generatedFilesDir: string;
|
||||
logsDir: string;
|
||||
mcpConfigDir: string;
|
||||
pluginStateDir: string;
|
||||
previewStateDir: string;
|
||||
skillsCacheDir: string;
|
||||
tempDir: string;
|
||||
toolConfigDir: string;
|
||||
}
|
||||
|
||||
export interface SandboxRuntimeConfig {
|
||||
enabled: boolean;
|
||||
dataDir: string;
|
||||
roots: SandboxRuntimeRoots;
|
||||
}
|
||||
|
||||
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSY_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
||||
|
||||
export function isSandboxModeEnabled(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
const raw = env[SANDBOX_MODE_ENV];
|
||||
if (typeof raw !== 'string') return false;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (TRUTHY_VALUES.has(value)) return true;
|
||||
if (FALSY_VALUES.has(value)) return false;
|
||||
throw new Error(
|
||||
`${SANDBOX_MODE_ENV} must be one of ${Array.from(TRUTHY_VALUES).join(', ')} ` +
|
||||
`or ${Array.from(FALSY_VALUES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSandboxRuntimeConfig(
|
||||
enabled: boolean,
|
||||
dataDir: string,
|
||||
): SandboxRuntimeConfig {
|
||||
const sandboxRoot = path.join(dataDir, 'sandbox');
|
||||
return {
|
||||
enabled,
|
||||
dataDir,
|
||||
roots: {
|
||||
agentHomeDir: path.join(sandboxRoot, 'agent-home'),
|
||||
cacheDir: path.join(sandboxRoot, 'cache'),
|
||||
configDir: path.join(sandboxRoot, 'config'),
|
||||
generatedFilesDir: path.join(dataDir, 'generated-files'),
|
||||
logsDir: path.join(dataDir, 'logs'),
|
||||
mcpConfigDir: dataDir,
|
||||
pluginStateDir: path.join(dataDir, 'plugins'),
|
||||
previewStateDir: path.join(dataDir, 'previews'),
|
||||
skillsCacheDir: path.join(dataDir, 'skills'),
|
||||
tempDir: path.join(sandboxRoot, 'tmp'),
|
||||
toolConfigDir: path.join(sandboxRoot, 'tools'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSandboxRuntimeConfigFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
projectRoot: string,
|
||||
): SandboxRuntimeConfig | null {
|
||||
if (!isSandboxModeEnabled(env)) return null;
|
||||
const rawDataDir = env.OD_DATA_DIR?.trim();
|
||||
if (!rawDataDir) {
|
||||
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
}
|
||||
return resolveSandboxRuntimeConfig(
|
||||
true,
|
||||
resolveProjectRelativePath(rawDataDir, projectRoot),
|
||||
);
|
||||
}
|
||||
|
||||
export function sandboxAgentProfilesConfigPath(
|
||||
config: SandboxRuntimeConfig,
|
||||
): string {
|
||||
return path.join(
|
||||
config.roots.agentHomeDir,
|
||||
'.open-design',
|
||||
'agents.local.json',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureSandboxRuntimeDirs(config: SandboxRuntimeConfig): void {
|
||||
if (!config.enabled) return;
|
||||
for (const dir of new Set(Object.values(config.roots))) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function applySandboxRuntimeEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
config: SandboxRuntimeConfig,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!config.enabled) return baseEnv;
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
const { roots } = config;
|
||||
const codexHome = path.join(roots.agentHomeDir, '.codex');
|
||||
const claudeConfigDir = path.join(roots.configDir, 'claude');
|
||||
const opencodeHome = path.join(roots.agentHomeDir, '.opencode');
|
||||
const npmUserConfig = path.join(roots.toolConfigDir, 'npmrc');
|
||||
|
||||
env[SANDBOX_MODE_ENV] = '1';
|
||||
env.OD_DATA_DIR = config.dataDir;
|
||||
env.OD_AGENT_HOME = roots.agentHomeDir;
|
||||
env.HOME = roots.agentHomeDir;
|
||||
env.USERPROFILE = roots.agentHomeDir;
|
||||
env.XDG_CONFIG_HOME = roots.configDir;
|
||||
env.XDG_CACHE_HOME = roots.cacheDir;
|
||||
env.XDG_DATA_HOME = path.join(roots.configDir, 'data');
|
||||
env.XDG_STATE_HOME = path.join(roots.configDir, 'state');
|
||||
env.TMPDIR = roots.tempDir;
|
||||
env.TEMP = roots.tempDir;
|
||||
env.TMP = roots.tempDir;
|
||||
env.CODEX_HOME = codexHome;
|
||||
env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||
env.OPENCODE_TEST_HOME = opencodeHome;
|
||||
env.OD_AGENT_PROFILES_CONFIG = sandboxAgentProfilesConfigPath(config);
|
||||
env.NPM_CONFIG_USERCONFIG = npmUserConfig;
|
||||
env.npm_config_userconfig = npmUserConfig;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -25,7 +25,10 @@ import {
|
|||
shouldRenderCodexImagegenOverride,
|
||||
} from './prompts/system.js';
|
||||
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
|
||||
import { resolveProjectRoot } from './project-root.js';
|
||||
import { userFacingAgentLabel } from './user-facing-agent-label.js';
|
||||
|
||||
export { resolveProjectRoot };
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
|
||||
import {
|
||||
|
|
@ -90,6 +93,12 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
|
|||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||
import { syncCommunityPets } from './community-pets-sync.js';
|
||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
ensureSandboxRuntimeDirs,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
} from './sandbox-mode.js';
|
||||
import {
|
||||
createUserDesignSystem,
|
||||
deleteUserDesignSystem,
|
||||
|
|
@ -252,6 +261,7 @@ import {
|
|||
type ObservabilityEventRequest,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import {
|
||||
mergeNoProxyWithLoopbackDefaults,
|
||||
redactSecrets,
|
||||
testAgentConnection,
|
||||
testProviderConnection,
|
||||
|
|
@ -335,6 +345,7 @@ import {
|
|||
buildBatchArchive,
|
||||
decodeMultipartFilename,
|
||||
deleteProjectFile,
|
||||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
ensureProject,
|
||||
isSafeId,
|
||||
|
|
@ -346,6 +357,7 @@ import {
|
|||
renameProjectFile,
|
||||
removeProjectDir,
|
||||
resolveProjectDir,
|
||||
SandboxImportedProjectError,
|
||||
sanitizeName,
|
||||
searchProjectFiles,
|
||||
resolveProjectDir,
|
||||
|
|
@ -476,13 +488,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH';
|
||||
export function resolveProjectRoot(moduleDir: string): string {
|
||||
const base = path.basename(moduleDir);
|
||||
const daemonDir =
|
||||
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
|
||||
return path.resolve(daemonDir, '../..');
|
||||
}
|
||||
|
||||
function cleanOptionalPath(value: string | undefined): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? path.resolve(value)
|
||||
|
|
@ -1328,8 +1333,14 @@ function createMarketplaceFetcher(seedId, bundledMarketplaceEntries) {
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveDataDir(raw, projectRoot) {
|
||||
if (!raw) return path.join(projectRoot, '.od');
|
||||
export function resolveDataDir(raw, projectRoot, options = {}) {
|
||||
const value = raw?.trim();
|
||||
if (!value) {
|
||||
if (options.requireExplicit) {
|
||||
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
}
|
||||
return path.join(projectRoot, '.od');
|
||||
}
|
||||
// expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and
|
||||
// OD_MEDIA_CONFIG_DIR can never split state under a $HOME-style value.
|
||||
// Some launchers (systemd unit files, NixOS modules, certain Docker
|
||||
|
|
@ -1338,7 +1349,7 @@ export function resolveDataDir(raw, projectRoot) {
|
|||
// expandHomePrefix turns those (and the ~ shorthand, with both / and \
|
||||
// separators) into os.homedir() before path.resolve runs so launch
|
||||
// surfaces stay consistent.
|
||||
const resolved = resolveProjectRelativePath(raw, projectRoot);
|
||||
const resolved = resolveProjectRelativePath(value, projectRoot);
|
||||
try {
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
fs.accessSync(resolved, fs.constants.W_OK);
|
||||
|
|
@ -1364,7 +1375,12 @@ export function resolveDataDir(raw, projectRoot) {
|
|||
}
|
||||
return resolved;
|
||||
}
|
||||
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
|
||||
const SANDBOX_MODE_ENABLED = isSandboxModeEnabled(process.env);
|
||||
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT, {
|
||||
requireExplicit: SANDBOX_MODE_ENABLED,
|
||||
});
|
||||
const SANDBOX_RUNTIME = resolveSandboxRuntimeConfig(SANDBOX_MODE_ENABLED, RUNTIME_DATA_DIR);
|
||||
ensureSandboxRuntimeDirs(SANDBOX_RUNTIME);
|
||||
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
|
||||
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
|
||||
// that compare it against a user-supplied realpath() result. On macOS, /var
|
||||
|
|
@ -1623,16 +1639,26 @@ export function createAgentRuntimeEnv(
|
|||
toolTokenGrant: { token?: string } | null = null,
|
||||
nodeBin: string = process.execPath,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
OD_DATA_DIR: RUNTIME_DATA_DIR,
|
||||
OD_DAEMON_URL: daemonUrl,
|
||||
OD_NODE_BIN: nodeBin,
|
||||
};
|
||||
const env: NodeJS.ProcessEnv = applySandboxRuntimeEnv(
|
||||
{
|
||||
...baseEnv,
|
||||
OD_DATA_DIR: RUNTIME_DATA_DIR,
|
||||
OD_DAEMON_URL: daemonUrl,
|
||||
OD_NODE_BIN: nodeBin,
|
||||
},
|
||||
SANDBOX_RUNTIME,
|
||||
);
|
||||
const sidecarIpcPath = baseEnv[SIDECAR_ENV.IPC_PATH];
|
||||
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
|
||||
env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
|
||||
}
|
||||
if (SANDBOX_RUNTIME.enabled) {
|
||||
const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy);
|
||||
if (noProxy) {
|
||||
env.NO_PROXY = noProxy;
|
||||
if (process.platform !== 'win32') env.no_proxy = noProxy;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the node binary directory is on PATH so agent sub-processes —
|
||||
// in particular npm .cmd shims on Windows that run `"node" script.js` —
|
||||
|
|
@ -3842,10 +3868,18 @@ export async function startServer({
|
|||
// Active only when OD_API_TOKEN is set. Loopback origins skip the
|
||||
// check (the desktop UI / local CLI never carry a bearer); every
|
||||
// other request must present `Authorization: Bearer <token>` with a
|
||||
// value matching `OD_API_TOKEN`. Health / version / status remain
|
||||
// open so monitoring probes don't need the token.
|
||||
// value matching `OD_API_TOKEN`. Health / readiness / version remain
|
||||
// open so monitoring probes don't need the token. Rich daemon status
|
||||
// stays authenticated because it includes local runtime paths.
|
||||
if (apiToken.length > 0) {
|
||||
const openProbePaths = new Set(['/api/health', '/api/version', '/api/daemon/status']);
|
||||
const openProbePaths = new Set([
|
||||
'/health',
|
||||
'/api/health',
|
||||
'/ready',
|
||||
'/api/ready',
|
||||
'/version',
|
||||
'/api/version',
|
||||
]);
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (openProbePaths.has(req.path)) return next();
|
||||
// Loopback short-circuit. We ignore the proxied X-Forwarded-For
|
||||
|
|
@ -4356,6 +4390,16 @@ export async function startServer({
|
|||
res.json({ ok: true, version: versionInfo.version });
|
||||
});
|
||||
|
||||
app.get('/api/ready', async (_req, res) => {
|
||||
const versionInfo = await readCurrentAppVersionInfo();
|
||||
const ready = !daemonShuttingDown;
|
||||
res.status(ready ? 200 : 503).json({
|
||||
ok: ready,
|
||||
ready,
|
||||
version: versionInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/version', async (_req, res) => {
|
||||
const version = await readCurrentAppVersionInfo();
|
||||
res.json({ version });
|
||||
|
|
@ -4409,6 +4453,10 @@ export async function startServer({
|
|||
port: Number(process.env.OD_PORT ?? 7456),
|
||||
dataDir: RUNTIME_DATA_DIR,
|
||||
mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null,
|
||||
sandboxMode: SANDBOX_RUNTIME.enabled,
|
||||
sandbox: SANDBOX_RUNTIME.enabled
|
||||
? { enabled: true, roots: SANDBOX_RUNTIME.roots }
|
||||
: { enabled: false },
|
||||
pid: process.pid,
|
||||
shuttingDown: daemonShuttingDown,
|
||||
installedPlugins: (() => {
|
||||
|
|
@ -10735,14 +10783,13 @@ export async function startServer({
|
|||
try {
|
||||
const chatProject = getProject(db, projectId);
|
||||
const chatMeta = chatProject?.metadata;
|
||||
if (chatMeta?.baseDir) {
|
||||
cwd = path.normalize(chatMeta.baseDir);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
|
||||
} else {
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
|
||||
assertSandboxProjectRootAvailable(chatMeta);
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxImportedProjectError) {
|
||||
return design.runs.fail(run, 'BAD_REQUEST', err.message);
|
||||
}
|
||||
} catch {
|
||||
cwd = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -13051,7 +13098,18 @@ export async function startServer({
|
|||
) {
|
||||
try {
|
||||
const convs = listConversations(db, meta.projectId);
|
||||
const defaultConv = Array.isArray(convs) && convs.length > 0 ? convs[0] : null;
|
||||
// listConversations is ordered for the UI by recent activity; this
|
||||
// fallback must bind to the seeded default conversation instead.
|
||||
const defaultConv = Array.isArray(convs) && convs.length > 0
|
||||
? [...convs].sort((a, b) => {
|
||||
const aCreated = Number(a?.createdAt);
|
||||
const bCreated = Number(b?.createdAt);
|
||||
if (Number.isFinite(aCreated) && Number.isFinite(bCreated) && aCreated !== bCreated) {
|
||||
return aCreated - bCreated;
|
||||
}
|
||||
return String(a?.id ?? '').localeCompare(String(b?.id ?? ''));
|
||||
})[0]
|
||||
: null;
|
||||
if (defaultConv && typeof defaultConv.id === 'string' && defaultConv.id) {
|
||||
meta.conversationId = defaultConv.id;
|
||||
if (typeof meta.assistantMessageId !== 'string' || !meta.assistantMessageId) {
|
||||
|
|
@ -13085,10 +13143,13 @@ export async function startServer({
|
|||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||
? appCfg.agentId
|
||||
: null;
|
||||
if (cfgAgent) {
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const cfgAgentAvailable = cfgAgent
|
||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||
: false;
|
||||
if (cfgAgent && cfgAgentAvailable) {
|
||||
meta.agentId = cfgAgent;
|
||||
} else {
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||
if (firstAvailable) meta.agentId = firstAvailable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
|
|||
expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR);
|
||||
});
|
||||
|
||||
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
|
||||
const env = createAgentRuntimeEnv(
|
||||
{ PATH: '/bin', HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: '' },
|
||||
'http://127.0.0.1:7456',
|
||||
{ token: 'fresh-token' },
|
||||
'/opt/open-design/bin/node',
|
||||
);
|
||||
|
||||
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9');
|
||||
expect(env.NO_PROXY).toBe('');
|
||||
expect(env.no_proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => {
|
||||
const env = createAgentRuntimeEnv(
|
||||
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// OD_API_TOKEN is set.
|
||||
// 2. When OD_API_TOKEN is set, every /api/* request from a non-loopback
|
||||
// peer must carry `Authorization: Bearer <OD_API_TOKEN>`. The
|
||||
// health/version/status probes stay open for monitoring.
|
||||
// health/readiness/version probes stay open for monitoring.
|
||||
//
|
||||
// Tests force the bearer-required code path by stamping the env vars
|
||||
// before startServer. The daemon listens on 127.0.0.1 throughout (so
|
||||
|
|
@ -77,8 +77,8 @@ describe('bearer middleware', () => {
|
|||
expect(resp.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps health / version / daemon-status open without a bearer', async () => {
|
||||
for (const path of ['/api/health', '/api/version', '/api/daemon/status']) {
|
||||
it('keeps health / readiness / version probes open without a bearer', async () => {
|
||||
for (const path of ['/api/health', '/api/ready', '/api/version']) {
|
||||
const resp = await fetch(`${baseUrl}${path}`);
|
||||
expect(resp.status).toBe(200);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ describe('GET /api/daemon/status', () => {
|
|||
version: unknown;
|
||||
bindHost: unknown;
|
||||
port: unknown;
|
||||
sandboxMode: boolean;
|
||||
sandbox: { enabled: boolean };
|
||||
pid: unknown;
|
||||
installedPlugins: unknown;
|
||||
shuttingDown: boolean;
|
||||
|
|
@ -49,11 +51,32 @@ describe('GET /api/daemon/status', () => {
|
|||
expect(typeof body.port).toBe('number');
|
||||
expect(typeof body.pid).toBe('number');
|
||||
expect(typeof body.installedPlugins).toBe('number');
|
||||
expect(body.sandboxMode).toBe(false);
|
||||
expect(body.sandbox).toEqual({ enabled: false });
|
||||
expect(body.shuttingDown).toBe(false);
|
||||
expect(body).not.toHaveProperty('namespace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ready', () => {
|
||||
it('returns a readiness snapshot for headless launchers', async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/ready`);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
ready: boolean;
|
||||
version: unknown;
|
||||
};
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.ready).toBe(true);
|
||||
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
|
||||
expect(body).not.toHaveProperty('dataDir');
|
||||
expect(body).not.toHaveProperty('sandboxMode');
|
||||
expect(body).not.toHaveProperty('sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/daemon/shutdown', () => {
|
||||
it('only accepts requests from local-daemon-allowed origins', async () => {
|
||||
// Without the local-daemon header, the route is rejected. The
|
||||
|
|
|
|||
|
|
@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
|
|||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js';
|
||||
import {
|
||||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
listFiles,
|
||||
resolveProjectDir,
|
||||
SandboxImportedProjectError,
|
||||
} from '../src/projects.js';
|
||||
|
||||
function withSandboxMode<T>(run: () => T): T {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveProjectDir', () => {
|
||||
const projectsRoot = '/var/od/projects';
|
||||
|
|
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
|
|||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
|
||||
withSandboxMode(() => {
|
||||
const baseDir = '/Users/me/projects/site';
|
||||
expect(
|
||||
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
|
||||
).toThrowError(SandboxImportedProjectError);
|
||||
expect(() =>
|
||||
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
|
||||
).toThrowError(SandboxImportedProjectError);
|
||||
expect(() => resolveProjectDir(projectsRoot, '../escape', {
|
||||
kind: 'prototype',
|
||||
baseDir,
|
||||
})).toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEntryFile', () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,37 @@ describe('POST /api/import/folder', () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunStatus(
|
||||
runId: string,
|
||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||
let lastStatus = 'unknown';
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||
const statusBody = (await statusResponse.json()) as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
lastStatus = statusBody.status;
|
||||
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
|
||||
return statusBody;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
|
||||
}
|
||||
|
||||
it('creates a project rooted at the submitted folder', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
|
@ -62,6 +93,80 @@ describe('POST /api/import/folder', () => {
|
|||
expect(body.entryFile).toBe('index.html');
|
||||
});
|
||||
|
||||
it('rejects folder imports in sandbox mode', async () => {
|
||||
await withSandboxMode(async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const resp = await importFolder({ baseDir: folder });
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: project.id,
|
||||
message: 'Inspect the imported project.',
|
||||
}),
|
||||
});
|
||||
expect(runResp.status).toBe(202);
|
||||
const { runId } = (await runResp.json()) as { runId: string };
|
||||
const status = await waitForRunStatus(runId);
|
||||
expect(status.status).toBe('failed');
|
||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
||||
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('still opens an imported-folder project record in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${project.id}`);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
project?: { id?: string; metadata?: { baseDir?: string } };
|
||||
};
|
||||
expect(body.project?.id).toBe(project.id);
|
||||
expect(body.project?.metadata?.baseDir).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects imported-folder project file listing in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-detects the entry file when present', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '');
|
||||
|
|
|
|||
194
apps/daemon/tests/headless-runs.test.ts
Normal file
194
apps/daemon/tests/headless-runs.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import type http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
type StartedServer = {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
shutdown?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
describe('POST /api/runs headless fallbacks', () => {
|
||||
let started: StartedServer | null = null;
|
||||
const oldPath = process.env.PATH;
|
||||
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.resolve(started?.shutdown?.());
|
||||
if (started?.server) {
|
||||
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
|
||||
}
|
||||
started = null;
|
||||
if (oldPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = oldPath;
|
||||
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||
});
|
||||
|
||||
it('binds omitted conversationId to the seeded project conversation', async () => {
|
||||
started = await startTestServer();
|
||||
const { projectId, conversationId: seededConversationId } = await createProject(
|
||||
started.url,
|
||||
'Headless default conversation project',
|
||||
);
|
||||
await delay(5);
|
||||
const newerConversationId = await createConversation(started.url, projectId, 'Newer user chat');
|
||||
|
||||
const conversationsResponse = await fetch(
|
||||
`${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||
);
|
||||
expect(conversationsResponse.status).toBe(200);
|
||||
const conversationsBody = await conversationsResponse.json() as {
|
||||
conversations: Array<{ id: string }>;
|
||||
};
|
||||
expect(conversationsBody.conversations[0]?.id).toBe(newerConversationId);
|
||||
|
||||
const runResponse = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: `missing-agent-${randomUUID()}`,
|
||||
projectId,
|
||||
message: 'Headless prompt',
|
||||
}),
|
||||
});
|
||||
expect(runResponse.status).toBe(202);
|
||||
const runBody = await runResponse.json() as { conversationId: string | null };
|
||||
expect(runBody.conversationId).toBe(seededConversationId);
|
||||
});
|
||||
|
||||
it('falls back past a stale saved agent to the first detected available runtime', async () => {
|
||||
started = await startTestServer();
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-bin-'));
|
||||
const emptyAgentHome = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-home-'));
|
||||
const priorConfig = await readAppConfigFromServer(started.url);
|
||||
try {
|
||||
const opencodeBin = await writeFakeOpencode(binDir);
|
||||
process.env.PATH = '';
|
||||
process.env.OD_AGENT_HOME = emptyAgentHome;
|
||||
|
||||
const configResponse = await fetch(`${started.url}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_BIN: path.join(binDir, 'missing-claude') },
|
||||
opencode: { OPENCODE_BIN: opencodeBin },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(configResponse.status).toBe(200);
|
||||
|
||||
const { projectId } = await createProject(started.url, 'Headless stale agent project');
|
||||
const runResponse = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
message: 'Headless prompt',
|
||||
}),
|
||||
});
|
||||
expect(runResponse.status).toBe(202);
|
||||
const runBody = await runResponse.json() as { runId: string };
|
||||
const statusResponse = await fetch(
|
||||
`${started.url}/api/runs/${encodeURIComponent(runBody.runId)}`,
|
||||
);
|
||||
expect(statusResponse.status).toBe(200);
|
||||
const statusBody = await statusResponse.json() as { agentId: string | null };
|
||||
expect(statusBody.agentId).toBe('opencode');
|
||||
} finally {
|
||||
await restoreAppConfig(started.url, priorConfig);
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
await rm(emptyAgentHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function startTestServer(): Promise<StartedServer> {
|
||||
return await startServer({ port: 0, returnServer: true }) as StartedServer;
|
||||
}
|
||||
|
||||
async function createProject(url: string, name: string): Promise<{
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
}> {
|
||||
const projectId = `project_${randomUUID()}`;
|
||||
const response = await fetch(`${url}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: projectId,
|
||||
name,
|
||||
metadata: { kind: 'prototype' },
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { conversationId: string };
|
||||
return { projectId, conversationId: body.conversationId };
|
||||
}
|
||||
|
||||
async function createConversation(
|
||||
url: string,
|
||||
projectId: string,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${url}/api/projects/${encodeURIComponent(projectId)}/conversations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { conversation: { id: string } };
|
||||
return body.conversation.id;
|
||||
}
|
||||
|
||||
async function readAppConfigFromServer(url: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${url}/api/app-config`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { config?: Record<string, unknown> };
|
||||
return body.config ?? {};
|
||||
}
|
||||
|
||||
async function restoreAppConfig(url: string, config: Record<string, unknown>): Promise<void> {
|
||||
await fetch(`${url}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: Object.hasOwn(config, 'agentId') ? config.agentId : null,
|
||||
agentCliEnv: Object.hasOwn(config, 'agentCliEnv') ? config.agentCliEnv : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFakeOpencode(dir: string): Promise<string> {
|
||||
const bin = path.join(dir, 'opencode');
|
||||
await writeFile(bin, `#!/usr/bin/env node
|
||||
if (process.argv.includes('--version')) {
|
||||
console.log('opencode 0.0.0');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === 'models') {
|
||||
console.log('test/model');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === 'run') {
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
`, 'utf8');
|
||||
await chmod(bin, 0o755);
|
||||
return bin;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
);
|
||||
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
||||
const originalDataDir = process.env.OD_DATA_DIR;
|
||||
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
|
||||
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
}
|
||||
delete process.env.OD_MEDIA_CONFIG_DIR;
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
} else {
|
||||
process.env.OD_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalSandboxMode == null) {
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
} else {
|
||||
process.env.OD_SANDBOX_MODE = originalSandboxMode;
|
||||
}
|
||||
homedirSpy.mockRestore();
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
|
|
@ -124,6 +131,30 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not read host OpenAI auth files in sandbox mode', async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
await writeHomeJson('.hermes/auth.json', {
|
||||
providers: {
|
||||
'openai-codex': {
|
||||
tokens: { access_token: 'hermes-oauth-token' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeHomeJson('.codex/auth.json', {
|
||||
tokens: { access_token: 'codex-oauth-token' },
|
||||
OPENAI_API_KEY: 'host-codex-api-key',
|
||||
});
|
||||
|
||||
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
||||
const masked = await readMaskedConfig(projectRoot);
|
||||
|
||||
expect(resolved.apiKey).toBe('');
|
||||
expect(openaiProvider(masked)).toMatchObject({
|
||||
configured: false,
|
||||
source: 'unset',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
|
||||
await writeHomeJson('.codex/auth.json', {
|
||||
tokens: { access_token: 'codex-oauth-token' },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type http from 'node:http';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
|
||||
|
||||
type FakeMediaEndpoint = 'tool' | 'legacy';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
|
|||
let binDir: string;
|
||||
let oldPath: string | undefined;
|
||||
let oldCapture: string | undefined;
|
||||
let oldMemoryConfigRaw: string | null = null;
|
||||
let server: http.Server | null = null;
|
||||
let shutdown: (() => Promise<void> | void) | undefined;
|
||||
|
||||
|
|
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
|
|||
oldPath = process.env.PATH;
|
||||
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`;
|
||||
const memoryConfig = memoryConfigPath();
|
||||
oldMemoryConfigRaw = await readFile(memoryConfig, 'utf8').catch(() => null);
|
||||
await writeMemoryConfig(process.env.OD_DATA_DIR!, {
|
||||
chatExtractionEnabled: false,
|
||||
extraction: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
|
|||
else process.env.PATH = oldPath;
|
||||
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture;
|
||||
const memoryConfig = memoryConfigPath();
|
||||
if (oldMemoryConfigRaw === null) {
|
||||
await rm(memoryConfig, { force: true });
|
||||
} else {
|
||||
await mkdir(path.dirname(memoryConfig), { recursive: true });
|
||||
await writeFile(memoryConfig, oldMemoryConfigRaw);
|
||||
}
|
||||
oldMemoryConfigRaw = null;
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
});
|
||||
|
|
@ -468,6 +484,10 @@ describe('run-scoped media policy routes', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function memoryConfigPath(): string {
|
||||
return path.join(memoryDir(process.env.OD_DATA_DIR!), '.config.json');
|
||||
}
|
||||
|
||||
async function writeFakeAgent(
|
||||
capturePath: string,
|
||||
requestBody: unknown,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
expect(detail.resolvedDir).toBe(baseDir);
|
||||
});
|
||||
|
||||
it('keeps imported-folder resolvedDir stable in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseDir: folder }),
|
||||
});
|
||||
expect(importResp.status).toBe(200);
|
||||
const importBody = (await importResp.json()) as {
|
||||
project: { id: string; metadata?: { baseDir?: string } };
|
||||
};
|
||||
const projectId = importBody.project.id;
|
||||
const baseDir = importBody.project.metadata?.baseDir;
|
||||
expect(baseDir).toBeTruthy();
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||
expect(detailResp.status).toBe(200);
|
||||
const detail = (await detailResp.json()) as {
|
||||
project: { id: string };
|
||||
resolvedDir: string;
|
||||
};
|
||||
expect(detail.project.id).toBe(projectId);
|
||||
expect(detail.resolvedDir).toBe(baseDir);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
|
||||
const projectId = `proj-routes-${Date.now()}`;
|
||||
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||
|
|
@ -269,3 +298,14 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
|
||||
});
|
||||
});
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ describe('resolveDataDir', () => {
|
|||
expect(resolveDataDir('', projectRoot)).toBe(path.join(projectRoot, '.od'));
|
||||
});
|
||||
|
||||
it('requires an explicit OD_DATA_DIR when sandbox mode requires one', () => {
|
||||
expect(() =>
|
||||
resolveDataDir(undefined, projectRoot, { requireExplicit: true }),
|
||||
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
expect(() => resolveDataDir('', projectRoot, { requireExplicit: true })).toThrow(
|
||||
'OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled',
|
||||
);
|
||||
expect(() =>
|
||||
resolveDataDir(' ', projectRoot, { requireExplicit: true }),
|
||||
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
});
|
||||
|
||||
it('trims OD_DATA_DIR before resolving the storage root', () => {
|
||||
const out = resolveDataDir(' rel-od ', projectRoot, { requireExplicit: true });
|
||||
expect(out).toBe(path.join(projectRoot, 'rel-od'));
|
||||
});
|
||||
|
||||
it('expands a leading ~/ against the user home directory', () => {
|
||||
const out = resolveDataDir('~/od-test', projectRoot);
|
||||
expect(out).toBe(path.join(fakeHome, 'od-test'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { symlinkSync } from 'node:fs';
|
||||
import { test, vi } from 'vitest';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as platform from '@open-design/platform';
|
||||
import {
|
||||
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
|
||||
|
|
@ -8,6 +10,7 @@ import {
|
|||
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
|
||||
|
||||
const fsTest = process.platform === 'win32' ? test.skip : test;
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
|
||||
// credentials, silently billing API usage. Strip it for the claude
|
||||
|
|
@ -55,6 +58,113 @@ test('spawnEnvForAgent applies configured Codex env without mutating the base en
|
|||
assert.equal('CODEX_BIN' in base, false);
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent reapplies sandbox state roots after configured env overrides', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-'));
|
||||
try {
|
||||
const codexEnv = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
codexEnv.CODEX_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.codex'),
|
||||
);
|
||||
assert.equal(codexEnv.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
|
||||
const claudeEnv = spawnEnvForAgent(
|
||||
'claude',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CLAUDE_CONFIG_DIR: '/Users/test/.claude-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
claudeEnv.CLAUDE_CONFIG_DIR,
|
||||
join(dataDir, 'sandbox', 'config', 'claude'),
|
||||
);
|
||||
|
||||
const amrEnv = spawnEnvForAgent(
|
||||
'amr',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
OPENCODE_TEST_HOME: '/Users/test/.opencode-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
amrEnv.OPENCODE_TEST_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.opencode'),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent keeps sandbox roots pinned to the base OD_DATA_DIR', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-base-'));
|
||||
try {
|
||||
const env = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
OD_DATA_DIR: '/host/path/.od',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(env.OD_DATA_DIR, dataDir);
|
||||
assert.equal(env.CODEX_HOME, join(dataDir, 'sandbox', 'agent-home', '.codex'));
|
||||
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent resolves relative OD_DATA_DIR before applying sandbox roots', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-relative-'));
|
||||
try {
|
||||
const relativeDataDir = relative(repoRoot, dataDir);
|
||||
const env = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: relativeDataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
env.CODEX_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.codex'),
|
||||
);
|
||||
assert.equal(env.CLAUDE_CONFIG_DIR, join(dataDir, 'sandbox', 'config', 'claude'));
|
||||
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
|
||||
const env = spawnEnvForAgent(
|
||||
'gemini',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test } from 'vitest';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import {
|
||||
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
|
|
@ -407,6 +408,77 @@ fsTest(
|
|||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
|
||||
() => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
|
||||
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
|
||||
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
|
||||
const realPrefixBin = join(realPrefix, 'bin');
|
||||
try {
|
||||
return withEnvSnapshot(
|
||||
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
|
||||
() => {
|
||||
mkdirSync(realPrefixBin, { recursive: true });
|
||||
writeFileSync(join(realPrefixBin, 'gemini'), '');
|
||||
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
|
||||
|
||||
delete process.env.OD_AGENT_HOME;
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.PATH = emptyPath;
|
||||
process.env.NPM_CONFIG_PREFIX = realPrefix;
|
||||
|
||||
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
|
||||
assert.equal(
|
||||
resolved,
|
||||
null,
|
||||
`sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(emptyPath, { recursive: true, force: true });
|
||||
rmSync(realPrefix, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
|
||||
() => {
|
||||
const projectRoot = resolve(process.cwd(), '../..');
|
||||
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
|
||||
const dataDir = join(parent, 'data');
|
||||
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
|
||||
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
|
||||
try {
|
||||
return withEnvSnapshot(
|
||||
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
|
||||
() => {
|
||||
mkdirSync(sandboxBin, { recursive: true });
|
||||
const geminiPath = join(sandboxBin, 'gemini');
|
||||
writeFileSync(geminiPath, '');
|
||||
chmodSync(geminiPath, 0o755);
|
||||
|
||||
delete process.env.OD_AGENT_HOME;
|
||||
delete process.env.NPM_CONFIG_PREFIX;
|
||||
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.PATH = emptyPath;
|
||||
|
||||
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
|
||||
assert.equal(resolved, geminiPath);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
rmSync(emptyPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,31 @@ test('local agent profiles skip explicit unknown baseAgent without falling back'
|
|||
}
|
||||
});
|
||||
|
||||
test('sandbox mode ignores implicit and host explicit local agent profiles', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-sandbox-'));
|
||||
try {
|
||||
await withEnvSnapshot(['OD_AGENT_PROFILES_CONFIG', 'OD_SANDBOX_MODE', 'OD_DATA_DIR'], async () => {
|
||||
const config = join(dir, 'agents.local.json');
|
||||
writeFileSync(
|
||||
config,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'explicit-wrapper', bin: 'explicit-wrapper' }],
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
assert.deepEqual(readLocalAgentProfileDefs(), []);
|
||||
|
||||
process.env.OD_AGENT_PROFILES_CONFIG = config;
|
||||
assert.deepEqual(readLocalAgentProfileDefs(), []);
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
|
||||
process.env.OD_CODEX_DISABLE_PLUGINS = '1';
|
||||
|
||||
|
|
|
|||
98
apps/daemon/tests/sandbox-mode.test.ts
Normal file
98
apps/daemon/tests/sandbox-mode.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { existsSync, mkdtempSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
ensureSandboxRuntimeDirs,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
} from '../src/sandbox-mode.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function tempDataDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'od-sandbox-mode-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('sandbox mode env parsing', () => {
|
||||
it('is disabled when OD_SANDBOX_MODE is unset or false-like', () => {
|
||||
expect(isSandboxModeEnabled({})).toBe(false);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '0' })).toBe(false);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'false' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled for explicit true-like values', () => {
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '1' })).toBe(true);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'true' })).toBe(true);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'YES' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects ambiguous non-empty values', () => {
|
||||
expect(() => isSandboxModeEnabled({ OD_SANDBOX_MODE: 'sandbox' })).toThrow(
|
||||
'OD_SANDBOX_MODE must be one of',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sandbox runtime roots', () => {
|
||||
it('keeps all run-scoped roots under OD_DATA_DIR', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const config = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
|
||||
expect(config.enabled).toBe(true);
|
||||
for (const dir of Object.values(config.roots)) {
|
||||
expect(dir === dataDir || dir.startsWith(dataDir + path.sep)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates scoped runtime directories only when enabled', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const enabled = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
const disabled = resolveSandboxRuntimeConfig(false, dataDir);
|
||||
|
||||
ensureSandboxRuntimeDirs(disabled);
|
||||
expect(existsSync(enabled.roots.agentHomeDir)).toBe(false);
|
||||
|
||||
ensureSandboxRuntimeDirs(enabled);
|
||||
expect(existsSync(enabled.roots.agentHomeDir)).toBe(true);
|
||||
expect(existsSync(enabled.roots.previewStateDir)).toBe(true);
|
||||
expect(existsSync(enabled.roots.toolConfigDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('pins agent home and tool config env to sandbox roots', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const config = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
const env = applySandboxRuntimeEnv(
|
||||
{
|
||||
HOME: '/real/home',
|
||||
CODEX_HOME: '/real/home/.codex',
|
||||
CLAUDE_CONFIG_DIR: '/real/home/.claude',
|
||||
OPENCODE_TEST_HOME: '/real/home/.opencode',
|
||||
NPM_CONFIG_USERCONFIG: '/real/home/.npmrc',
|
||||
OD_DATA_DIR: dataDir,
|
||||
PATH: '/bin',
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
expect(env.HOME).toBe(config.roots.agentHomeDir);
|
||||
expect(env.USERPROFILE).toBe(config.roots.agentHomeDir);
|
||||
expect(env.OD_AGENT_HOME).toBe(config.roots.agentHomeDir);
|
||||
expect(env.CODEX_HOME).toBe(path.join(config.roots.agentHomeDir, '.codex'));
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe(path.join(config.roots.configDir, 'claude'));
|
||||
expect(env.OPENCODE_TEST_HOME).toBe(path.join(config.roots.agentHomeDir, '.opencode'));
|
||||
expect(env.NPM_CONFIG_USERCONFIG).toBe(path.join(config.roots.toolConfigDir, 'npmrc'));
|
||||
expect(env.PATH).toBe('/bin');
|
||||
});
|
||||
});
|
||||
142
apps/daemon/tests/sandbox-runtime-bootstrap.test.ts
Normal file
142
apps/daemon/tests/sandbox-runtime-bootstrap.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test, vi } from 'vitest';
|
||||
|
||||
function withEnvSnapshot<T>(
|
||||
keys: readonly string[],
|
||||
run: () => T | Promise<T>,
|
||||
): T | Promise<T> {
|
||||
const snapshot = new Map(keys.map((key) => [key, process.env[key]]));
|
||||
const restore = () => {
|
||||
for (const key of keys) {
|
||||
const value = snapshot.get(key);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result: T | Promise<T>;
|
||||
try {
|
||||
result = run();
|
||||
} catch (error) {
|
||||
restore();
|
||||
throw error;
|
||||
}
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(restore);
|
||||
}
|
||||
restore();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('sandbox runtime registry ignores host-local agent profiles at module load', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-'));
|
||||
const dataDir = path.join(root, 'data');
|
||||
const hostHome = path.join(root, 'host-home');
|
||||
const hostConfigDir = path.join(hostHome, '.open-design');
|
||||
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
|
||||
const sandboxConfigDir = path.join(
|
||||
dataDir,
|
||||
'sandbox',
|
||||
'agent-home',
|
||||
'.open-design',
|
||||
);
|
||||
const sandboxConfig = path.join(sandboxConfigDir, 'agents.local.json');
|
||||
|
||||
try {
|
||||
mkdirSync(hostConfigDir, { recursive: true });
|
||||
mkdirSync(sandboxConfigDir, { recursive: true });
|
||||
writeFileSync(
|
||||
hostConfig,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
sandboxConfig,
|
||||
JSON.stringify({
|
||||
agents: [
|
||||
{
|
||||
id: 'sandbox-wrapper',
|
||||
baseAgent: 'claude',
|
||||
bin: 'sandbox-wrapper',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withEnvSnapshot(
|
||||
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
|
||||
async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
process.env.OD_AGENT_PROFILES_CONFIG = hostConfig;
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', async () => ({
|
||||
...(await vi.importActual<typeof import('node:os')>('node:os')),
|
||||
homedir: () => hostHome,
|
||||
}));
|
||||
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
|
||||
const ids = AGENT_DEFS.map((def) => def.id);
|
||||
|
||||
assert.equal(ids.includes('host-wrapper'), false);
|
||||
assert.equal(ids.includes('sandbox-wrapper'), true);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('sandbox runtime registry ignores implicit profiles without OD_DATA_DIR', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-missing-data-'));
|
||||
const hostHome = path.join(root, 'host-home');
|
||||
const hostConfigDir = path.join(hostHome, '.open-design');
|
||||
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
|
||||
|
||||
try {
|
||||
mkdirSync(hostConfigDir, { recursive: true });
|
||||
writeFileSync(
|
||||
hostConfig,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
|
||||
}),
|
||||
);
|
||||
|
||||
await withEnvSnapshot(
|
||||
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
|
||||
async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', async () => ({
|
||||
...(await vi.importActual<typeof import('node:os')>('node:os')),
|
||||
homedir: () => hostHome,
|
||||
}));
|
||||
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
|
||||
const ids = AGENT_DEFS.map((def) => def.id);
|
||||
|
||||
assert.equal(ids.includes('host-wrapper'), false);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -47,6 +47,11 @@ import {
|
|||
type InlineMentionEntity,
|
||||
} from '../utils/inlineMentions';
|
||||
import { isImeComposing } from '../utils/imeComposing';
|
||||
import {
|
||||
reconcileInsertions,
|
||||
stripPluginInsertedTokens,
|
||||
type TrackedInsertion,
|
||||
} from '../utils/pluginInsertionTracking';
|
||||
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
|
@ -224,7 +229,23 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
|
||||
const [draft, setDraft] = useState(
|
||||
() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
|
||||
);
|
||||
// Synchronous mirror of the latest committed draft value.
|
||||
// `updateDraft` reads this as `prev` instead of relying on the
|
||||
// closure `draft` (which only updates after re-render) or
|
||||
// `setDraft((prev) => …)` (whose updater is double-invoked
|
||||
// under React StrictMode and would mutate
|
||||
// `pluginInsertedTokensRef` twice). The ref is updated
|
||||
// synchronously by `updateDraft` before `setDraft`, so the
|
||||
// next call sees a fresh `prev` even when React batches
|
||||
// multiple updates within one tick. Initialized from the same
|
||||
// source as the React state to keep the two in lockstep on
|
||||
// first render. See `updateDraft` below and #2929 round 5.
|
||||
const draftRef = useRef<string>(
|
||||
initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
|
||||
);
|
||||
|
||||
// chat_panel page_view fires from ProjectView (which outlives
|
||||
// conversation switches) so the event measures real chat-panel
|
||||
|
|
@ -271,6 +292,77 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// or from the tools-menu "Details" affordance.
|
||||
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
|
||||
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
|
||||
// Instance-aware tracking for `@<token>` mentions this surface
|
||||
// inserted into the draft via the @-mention popover plugin-pick
|
||||
// path (`insertPluginMention`). Each entry pins the precise
|
||||
// start offset of `@`, so two `@Airbnb` mentions in the same
|
||||
// draft (one composer-inserted, one user-authored) are
|
||||
// distinguishable — the chip-clear strip removes only tracked
|
||||
// instances (#2929 round 3). See utils/pluginInsertionTracking.ts
|
||||
// for the diff/reconcile/strip primitives.
|
||||
//
|
||||
// Lifecycle invariants:
|
||||
// - add: `insertPluginMention` pushes { token, start } using the
|
||||
// `insertStart` returned by `replaceMentionWithText`
|
||||
// - reconcile: `handleChange` runs LCP/LCS diff on each
|
||||
// keystroke and shifts/drops entries whose offsets crossed
|
||||
// the edit, plus revalidates surviving entries against the
|
||||
// mention boundary so `@Airbnbify`-style corruption prunes
|
||||
// - clear: `reset()` empties the array on send; `onCleared`
|
||||
// strips by range and empties the array
|
||||
//
|
||||
// Tools-menu / details-modal applies route through
|
||||
// `pluginsSectionRef.current.applyById` without writing to the
|
||||
// draft, so the array stays empty for those surfaces and the
|
||||
// post-clear strip is a no-op. Every draft mutation in this
|
||||
// component goes through the `updateDraft` chokepoint, which
|
||||
// runs `reconcileInsertions` against the prev → next diff. That
|
||||
// includes typing, slash-command pick, file/MCP/connector
|
||||
// insertion, skill chip remove, annotation append, imperative
|
||||
// handle, post-send reset, and the on-cleared strip itself —
|
||||
// so a tracked offset can never go stale relative to the draft
|
||||
// and re-introduce the original #2881 orphan-mention symptom
|
||||
// (#2929 round 4).
|
||||
//
|
||||
// Each entry carries the `pluginId` of the apply that produced
|
||||
// it. When the active plugin changes (e.g. tools-menu `applyById`
|
||||
// replaces plugin A with plugin B without writing to the draft),
|
||||
// entries for the previous active plugin are dropped via
|
||||
// `setActivePlugin`. Without that, clearing B's chip would still
|
||||
// strip A's `@A` from the draft — silent user-text deletion in a
|
||||
// supported replace-plugin flow (#2929 round 6).
|
||||
const pluginInsertedTokensRef = useRef<TrackedInsertion[]>([]);
|
||||
// The plugin id whose chip is currently mounted in PluginsSection's
|
||||
// chip strip, or `null` after the strip clears or before any apply
|
||||
// succeeds. Updated via `setActivePlugin`, which also drops any
|
||||
// tracked entries whose `pluginId` does not match the new active
|
||||
// — a no-op for `insertPluginMention` (the new entry it just
|
||||
// pushed matches), critical for tools-menu / details-modal
|
||||
// applies that arrive without an accompanying draft insertion.
|
||||
const activePluginIdRef = useRef<string | null>(null);
|
||||
// Monotonic counter that hands out unique `insertionId` strings to
|
||||
// entries pushed by `insertPluginMention`. The id survives
|
||||
// `reconcileInsertions` (utils/pluginInsertionTracking.ts forwards
|
||||
// the field) so the in-flight handler's failure path can locate
|
||||
// its own tracked entry even after intervening reconciles or
|
||||
// `onCleared` mutations of the array (#2929 round 10 codex
|
||||
// review). Plain ref counter is enough — the id only needs to be
|
||||
// unique within a single composer instance and is never persisted.
|
||||
const insertionIdSeqRef = useRef(0);
|
||||
|
||||
// Single chokepoint for setting the active plugin. Routes every
|
||||
// `applyById` call so the tracker stays in lockstep with the
|
||||
// chip strip's currently-mounted plugin.
|
||||
function setActivePlugin(pluginId: string | null): void {
|
||||
if (activePluginIdRef.current === pluginId) return;
|
||||
if (pluginInsertedTokensRef.current.length > 0) {
|
||||
pluginInsertedTokensRef.current =
|
||||
pluginInsertedTokensRef.current.filter(
|
||||
(entry) => entry.pluginId === pluginId,
|
||||
);
|
||||
}
|
||||
activePluginIdRef.current = pluginId;
|
||||
}
|
||||
// Consolidated "tools" popover — a single dropdown anchored to the
|
||||
// leading sliders icon that hosts MCP / Import / Pet quick actions and
|
||||
// a shortcut to open the full Settings dialog. Replaces the previous
|
||||
|
|
@ -299,7 +391,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
if (initialDraft && initialDraft !== draft) {
|
||||
setDraft(initialDraft);
|
||||
updateDraft(initialDraft);
|
||||
seededRef.current = true;
|
||||
} else if (initialDraft === undefined) {
|
||||
seededRef.current = true;
|
||||
|
|
@ -614,7 +706,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// command's canonical insertion text.
|
||||
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setSlash(null);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
|
|
@ -658,7 +750,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const trimmed = draft.trim();
|
||||
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
|
||||
onOpenMcpSettings();
|
||||
setDraft('');
|
||||
updateDraft('');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -724,7 +816,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
return false;
|
||||
}
|
||||
}
|
||||
setDraft('');
|
||||
updateDraft('');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +824,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
ref,
|
||||
() => ({
|
||||
setDraft: (text: string) => {
|
||||
setDraft(text);
|
||||
updateDraft(text);
|
||||
seededRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
|
|
@ -743,7 +835,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
});
|
||||
},
|
||||
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
|
||||
setDraft(text);
|
||||
updateDraft(text);
|
||||
setStaged(attachments);
|
||||
setStagedVisualComments(commentAttachments);
|
||||
setStagedSkills([]);
|
||||
|
|
@ -768,8 +860,39 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
[]
|
||||
);
|
||||
|
||||
// Single chokepoint for every draft mutation. Reconciles the
|
||||
// tracked plugin-mention offsets against the prev → next diff so
|
||||
// any setDraft path — typing, slash command, file/MCP/connector
|
||||
// insertion, skill chip removal, annotation append, imperative
|
||||
// handle, post-send reset, on-cleared strip — keeps
|
||||
// `pluginInsertedTokensRef` in lockstep with the draft.
|
||||
//
|
||||
// Implementation note (#2929 round 5): the reconcile and the
|
||||
// ref mutation happen *outside* the `setDraft` updater, using
|
||||
// the synchronous `draftRef` mirror as `prev`. Putting them
|
||||
// inside `setDraft((prev) => …)` would not be safe under
|
||||
// React StrictMode, which double-invokes setState updaters in
|
||||
// development to detect impurity — the second invocation
|
||||
// would re-shift or re-drop already-reconciled entries,
|
||||
// bringing back the #2881 orphan-mention symptom for every
|
||||
// user keystroke in the dev build.
|
||||
function updateDraft(next: string | ((prev: string) => string)): void {
|
||||
const prev = draftRef.current;
|
||||
const value = typeof next === 'function' ? next(prev) : next;
|
||||
if (prev === value) return;
|
||||
if (pluginInsertedTokensRef.current.length > 0) {
|
||||
pluginInsertedTokensRef.current = reconcileInsertions(
|
||||
pluginInsertedTokensRef.current,
|
||||
prev,
|
||||
value,
|
||||
);
|
||||
}
|
||||
draftRef.current = value;
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft("");
|
||||
updateDraft("");
|
||||
setStaged([]);
|
||||
setStagedVisualComments([]);
|
||||
setStagedSkills([]);
|
||||
|
|
@ -778,6 +901,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
setUploadError(null);
|
||||
setMention(null);
|
||||
setSlash(null);
|
||||
// Drop tracked plugin-mention insertions when the draft is wiped
|
||||
// — otherwise a later chip clear would prune user-authored text
|
||||
// that happened to share a label with a previously-applied
|
||||
// plugin (#2929 round 2/3). Also clear the active-plugin id
|
||||
// so the next applyById is treated as a fresh activation
|
||||
// rather than a "same plugin re-apply" (#2929 round 6).
|
||||
pluginInsertedTokensRef.current = [];
|
||||
activePluginIdRef.current = null;
|
||||
}
|
||||
|
||||
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
|
||||
|
|
@ -829,7 +960,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// Also strip the matching `@<id>` token from the draft so the chip
|
||||
// and the textarea stay in sync. We allow trailing whitespace to be
|
||||
// collapsed too.
|
||||
setDraft((d) =>
|
||||
updateDraft((d) =>
|
||||
d
|
||||
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
|
||||
.replace(/\s{2,}/g, ' '),
|
||||
|
|
@ -1001,7 +1132,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}),
|
||||
]);
|
||||
}
|
||||
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
if (detail.note) updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
setStreamingAnnotationSendPending(true);
|
||||
textareaRef.current?.focus();
|
||||
ack({ ok: true });
|
||||
|
|
@ -1022,7 +1153,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}
|
||||
|
||||
if (detail.note) {
|
||||
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
ack({ ok: true });
|
||||
|
|
@ -1118,7 +1249,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart;
|
||||
setDraft(value);
|
||||
// Goes through the `updateDraft` chokepoint so the
|
||||
// plugin-mention offset reconcile runs on every keystroke,
|
||||
// matching every other setDraft path for free.
|
||||
updateDraft(value);
|
||||
// Keep the staged-skill chips in sync with the draft. If the user
|
||||
// hand-deletes an `@<id>` token from the textarea, the chip must
|
||||
// disappear too — otherwise submit() would still forward that id in
|
||||
|
|
@ -1165,7 +1299,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const after = draft.slice(cursor);
|
||||
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setMention(null);
|
||||
if (!staged.some((s) => s.path === filePath)) {
|
||||
setStaged((s) => [
|
||||
|
|
@ -1185,28 +1319,175 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}
|
||||
|
||||
async function insertPluginMention(record: InstalledPluginRecord) {
|
||||
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
|
||||
if (!inserted) return;
|
||||
await pluginsSectionRef.current?.applyById(record.id, record);
|
||||
// Snapshot tracker AND draft state before any mutation so we
|
||||
// can roll back if `applyById` fails (#2929 round 7). Without
|
||||
// this, an `/apply` 5xx leaves the draft holding a freshly
|
||||
// inserted `@<token>` whose chip never mounted — a user
|
||||
// clearing the previously-active plugin's chip would then
|
||||
// strip the user-visible `@<token>` they just picked, even
|
||||
// though that text is the only signal they have that
|
||||
// anything happened.
|
||||
const prevDraftValue = draftRef.current;
|
||||
const prevEntries = pluginInsertedTokensRef.current;
|
||||
const prevActiveId = activePluginIdRef.current;
|
||||
|
||||
const result = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
|
||||
if (!result) return;
|
||||
// Capture the post-insert draft *snapshot* — the value the
|
||||
// composer is in immediately after our optimistic write.
|
||||
// Used as a sentinel during the rollback below: if the
|
||||
// textarea is still in this state when `applyById` fails
|
||||
// (no user keystrokes during the await), we can fully
|
||||
// restore `prevDraftValue`. If the user typed during the
|
||||
// await, the draft has moved past the snapshot and we MUST
|
||||
// NOT clobber those edits with the stale `prevDraftValue`
|
||||
// (#2929 round 8 — the textarea stays interactive while
|
||||
// `/apply` is in flight, so this is a real prompt-data-loss
|
||||
// path).
|
||||
const postInsertDraft = draftRef.current;
|
||||
// Track the precise start offset of the inserted `@` so the
|
||||
// post-clear strip can excise exactly this instance, leaving
|
||||
// any user-authored `@<sameLabel>` elsewhere in the draft
|
||||
// untouched (#2929 round 3). Entry carries `pluginId` so a
|
||||
// later replace-plugin flow can drop it cleanly (#2929 round 6),
|
||||
// and an `insertionId` so this handler's failure path can
|
||||
// locate the entry it pushed even after `reconcileInsertions`
|
||||
// shifted offsets or `onCleared` mutated the array
|
||||
// (#2929 round 10).
|
||||
//
|
||||
// Push the new entry but DO NOT yet drop entries from the
|
||||
// previously-active plugin — that filter is committed only
|
||||
// after `applyById` resolves successfully (#2929 round 9
|
||||
// codex review). During the await, the chip strip still
|
||||
// shows the previously-mounted plugin and the textarea is
|
||||
// interactive: a user click on that chip's × must strip its
|
||||
// tracked entries (not the optimistic `@<target>` we just
|
||||
// pushed). `onCleared` filters by
|
||||
// `pluginsSectionRef.current?.getActiveRecord()?.id` so a
|
||||
// pending-window clear scopes to the actually-mounted
|
||||
// plugin's tracked tokens.
|
||||
const ourInsertionId = `i${++insertionIdSeqRef.current}`;
|
||||
pluginInsertedTokensRef.current = [
|
||||
...pluginInsertedTokensRef.current,
|
||||
{
|
||||
token: record.title,
|
||||
start: result.insertStart,
|
||||
pluginId: record.id,
|
||||
insertionId: ourInsertionId,
|
||||
},
|
||||
];
|
||||
|
||||
const applyResult = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (!applyResult) {
|
||||
// Two failure modes to disambiguate (#2929 round 10):
|
||||
//
|
||||
// (a) "no intervening clear" — the user neither cleared
|
||||
// the previously-mounted chip nor anything else
|
||||
// mutated the tracker beyond our push + reconciles
|
||||
// from user keystrokes. `prevEntries` and
|
||||
// `prevActiveId` are still the truth. We restore the
|
||||
// tracker wholesale and restore the draft only if
|
||||
// the user did not type during the await
|
||||
// (round 7/8 path).
|
||||
//
|
||||
// (b) "intervening clear" — `onCleared` ran during the
|
||||
// await for the previously-mounted chip, stripped
|
||||
// its tokens from the draft, and nulled
|
||||
// `activePluginIdRef`. Restoring `prevEntries`
|
||||
// wholesale here would resurrect already-stripped
|
||||
// entries with stale offsets, AND leave our
|
||||
// optimistic `@<target>` orphaned in the draft (the
|
||||
// original #2881 symptom recurring inside the
|
||||
// failure window). Instead we surgically remove ONLY
|
||||
// our own optimistic entry by `insertionId`, strip
|
||||
// its `@<target>` from the draft, and leave
|
||||
// everything `onCleared` did intact.
|
||||
//
|
||||
// Detection: `onCleared` always nulls
|
||||
// `activePluginIdRef.current`; our deferred
|
||||
// `setActivePlugin` never ran (we are in the failure
|
||||
// branch). So `activePluginIdRef.current === null` while
|
||||
// `prevActiveId !== null` is the smoking gun for an
|
||||
// intervening clear. (If `prevActiveId` was already null,
|
||||
// there was no chip to clear — no race possible.)
|
||||
const intervenedClear =
|
||||
activePluginIdRef.current === null && prevActiveId !== null;
|
||||
if (intervenedClear) {
|
||||
const cur = pluginInsertedTokensRef.current;
|
||||
const idx = cur.findIndex(
|
||||
(e) => e.insertionId === ourInsertionId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const ourEntry = cur[idx]!;
|
||||
// Splice our entry out first so `updateDraft`'s
|
||||
// internal `reconcileInsertions` operates on a tracker
|
||||
// that already excludes it (the strip range overlaps
|
||||
// the entry, which would drop it anyway, but splicing
|
||||
// first keeps the invariant explicit and avoids
|
||||
// depending on the reconcile drop edge case).
|
||||
pluginInsertedTokensRef.current = [
|
||||
...cur.slice(0, idx),
|
||||
...cur.slice(idx + 1),
|
||||
];
|
||||
updateDraft((d) => stripPluginInsertedTokens(d, [ourEntry]));
|
||||
}
|
||||
// Don't touch `activePluginIdRef` — `onCleared` set it
|
||||
// to null and that is the truth (no chip is mounted).
|
||||
return;
|
||||
}
|
||||
// (a) round 7/8 path: no intervening clear.
|
||||
pluginInsertedTokensRef.current = prevEntries;
|
||||
activePluginIdRef.current = prevActiveId;
|
||||
// Restore the draft only if no user keystrokes arrived
|
||||
// during the await — overwriting newer edits with the
|
||||
// stale pre-pick snapshot would be a worse bug than the
|
||||
// leftover `@<token>` styled mention this branch leaves
|
||||
// behind. The orphan stays as a styled mention but no
|
||||
// future chip clear will touch it (tracker is empty for
|
||||
// it now), and the user can edit it manually
|
||||
// (#2929 round 8).
|
||||
if (draftRef.current === postInsertDraft) {
|
||||
setDraft(prevDraftValue);
|
||||
draftRef.current = prevDraftValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Apply succeeded. Now commit the active-plugin switch —
|
||||
// this drops any entries from the previously-active plugin
|
||||
// (a no-op for the entry we just pushed since it matches
|
||||
// `record.id`) and updates `activePluginIdRef`. Deferring
|
||||
// until after the await means an `onCleared` triggered
|
||||
// during the in-flight window saw the still-mounted plugin
|
||||
// as the active one and stripped only that plugin's tokens
|
||||
// (#2929 round 9).
|
||||
setActivePlugin(record.id);
|
||||
}
|
||||
|
||||
function replaceMentionWithText(text: string): boolean {
|
||||
if (!mention) return false;
|
||||
function replaceMentionWithText(
|
||||
text: string,
|
||||
): { insertStart: number } | null {
|
||||
if (!mention) return null;
|
||||
const ta = textareaRef.current;
|
||||
const cursor = mention.cursor;
|
||||
const before = draft.slice(0, cursor);
|
||||
const after = draft.slice(cursor);
|
||||
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setMention(null);
|
||||
// The inserted text was appended onto `replaced`, so its first
|
||||
// char (the `@`) sits at `replaced.length - text.length`.
|
||||
const insertStart = replaced.length - text.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const pos = replaced.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
return true;
|
||||
return { insertStart };
|
||||
}
|
||||
|
||||
function insertMcpMention(server: McpServerConfig) {
|
||||
|
|
@ -1234,7 +1515,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
function removeStaged(p: string) {
|
||||
setStaged((s) => s.filter((a) => a.path !== p));
|
||||
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
|
||||
setDraft((current) => stripInlineMentionToken(current, p));
|
||||
updateDraft((current) => stripInlineMentionToken(current, p));
|
||||
}
|
||||
|
||||
function removeCommentAttachment(id: string) {
|
||||
|
|
@ -1473,12 +1754,73 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
showRail={false}
|
||||
onApplied={(brief) => {
|
||||
// Use functional setState so stale closures from the @-mention
|
||||
// flow (which awaits applyById after setDraft) still see the
|
||||
// latest draft value before deciding whether to seed.
|
||||
// flow (which awaits applyById after updateDraft) still see
|
||||
// the latest draft value before deciding whether to seed.
|
||||
if (typeof brief === 'string' && brief.length > 0) {
|
||||
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
|
||||
updateDraft((cur) => (cur.trim().length === 0 ? brief : cur));
|
||||
}
|
||||
}}
|
||||
onCleared={() => {
|
||||
// Removing the chip strip must drop the `@…` tokens
|
||||
// this surface authored, otherwise the textarea is
|
||||
// left holding orphaned mentions whose chips just
|
||||
// unmounted (#2881). We strip *only* the tracked
|
||||
// insertions (by precise start offset) so
|
||||
// user-authored text that happens to share a label
|
||||
// with a chip is preserved (#2929 round 3).
|
||||
//
|
||||
// The chip strip can clear while an `applyById` for
|
||||
// a *different* plugin is mid-await — the @-popover
|
||||
// optimistically writes `@<target>` and pushes a
|
||||
// tracked entry synchronously, then awaits the
|
||||
// apply (#2929 round 9 codex review). During that
|
||||
// window the ref carries entries for both the
|
||||
// still-mounted plugin (the chip the user is
|
||||
// removing) and the in-flight target. Trusting the
|
||||
// ref wholesale here would strip the optimistic
|
||||
// `@<target>` and leave the unmounting plugin's
|
||||
// `@<token>` orphaned — a recurrence of #2881 in a
|
||||
// pending-apply window.
|
||||
//
|
||||
// PluginsSection only flips `activeRecord` after
|
||||
// `applyPlugin` resolves successfully (see
|
||||
// `PluginsSection.tsx`), so `getActiveRecord()` at
|
||||
// the moment `onCleared` fires reports the plugin
|
||||
// whose chip is currently being unmounted — exactly
|
||||
// the one whose tracked entries we should strip.
|
||||
// Filter to that id; entries for any in-flight
|
||||
// replace target are left in place (the in-flight
|
||||
// handler's success path will commit
|
||||
// `setActivePlugin(target)` and drop them; its
|
||||
// failure path will roll the tracker back).
|
||||
const unmountingId =
|
||||
pluginsSectionRef.current?.getActiveRecord()?.id ?? null;
|
||||
const entries = pluginInsertedTokensRef.current;
|
||||
if (entries.length > 0) {
|
||||
const toStrip = unmountingId
|
||||
? entries.filter((e) => e.pluginId === unmountingId)
|
||||
: entries;
|
||||
if (toStrip.length > 0) {
|
||||
// `updateDraft` runs `reconcileInsertions`
|
||||
// against the prev → next diff inside the
|
||||
// chokepoint, so any in-flight target's entries
|
||||
// get their offsets shifted to track the
|
||||
// post-strip draft. We must re-read the ref
|
||||
// *after* `updateDraft` returns instead of
|
||||
// filtering the pre-strip `entries` snapshot,
|
||||
// otherwise we would clobber the reconciled
|
||||
// offsets and a later clear of the in-flight
|
||||
// chip would no-op via `isInsertionStillValid`.
|
||||
updateDraft((d) => stripPluginInsertedTokens(d, toStrip));
|
||||
}
|
||||
pluginInsertedTokensRef.current = unmountingId
|
||||
? pluginInsertedTokensRef.current.filter(
|
||||
(e) => e.pluginId !== unmountingId,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
activePluginIdRef.current = null;
|
||||
}}
|
||||
onChipDetails={(item: ContextItem) => {
|
||||
if (item.kind !== 'plugin') return;
|
||||
const record = installedPlugins.find((p) => p.id === item.id);
|
||||
|
|
@ -1700,11 +2042,32 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
plugins={pluginsForComposer}
|
||||
activePluginId={pinnedPluginId}
|
||||
onApply={async (record) => {
|
||||
// Tools-menu apply: no draft write, so the
|
||||
// tracked-insertion array gets no new
|
||||
// entry. The active-plugin switch (which
|
||||
// drops previously-tracked entries from a
|
||||
// prior @-popover pick of a different
|
||||
// plugin, #2929 round 6) is deferred until
|
||||
// `applyById` resolves successfully so
|
||||
// that an `onCleared` triggered during the
|
||||
// in-flight window still sees the
|
||||
// still-mounted plugin's entries and
|
||||
// strips them correctly via the
|
||||
// `getActiveRecord()` filter in
|
||||
// `onCleared` (#2929 round 9).
|
||||
//
|
||||
// No synchronous mutation in this branch
|
||||
// means no rollback snapshot is needed:
|
||||
// the failure path is just an early return
|
||||
// (#2929 round 7's snapshot was needed
|
||||
// because `setActivePlugin` was eager).
|
||||
const result = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (result) setToolsOpen(false);
|
||||
if (!result) return;
|
||||
setActivePlugin(record.id);
|
||||
setToolsOpen(false);
|
||||
}}
|
||||
onShowDetails={(record) => {
|
||||
setDetailsRecord(record);
|
||||
|
|
@ -1726,7 +2089,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const before = currentDraft.slice(0, cursor);
|
||||
const after = currentDraft.slice(cursor);
|
||||
const next = before + insert + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setToolsOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
|
|
@ -1750,7 +2113,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const before = draft.slice(0, cursor);
|
||||
const after = draft.slice(cursor);
|
||||
const next = before + insert + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setToolsOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
|
|
@ -1905,7 +2268,24 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
record={detailsRecord}
|
||||
onClose={() => setDetailsRecord(null)}
|
||||
onUse={async (record) => {
|
||||
await pluginsSectionRef.current?.applyById(record.id, record);
|
||||
// Details-modal apply: same shape as tools-menu apply
|
||||
// (no draft write). The active-plugin switch is
|
||||
// deferred until `applyById` resolves successfully so
|
||||
// that an `onCleared` triggered during the in-flight
|
||||
// window still sees the still-mounted plugin's
|
||||
// entries and strips them correctly (#2929 round 9).
|
||||
//
|
||||
// Modal closes regardless of apply outcome so the
|
||||
// user is not stuck on the details view if `/apply`
|
||||
// 5xx'd. Failure is a no-op: no synchronous mutation
|
||||
// happened, so nothing to roll back (#2929 round 7's
|
||||
// snapshot was needed because `setActivePlugin` was
|
||||
// eager — round 9 made it lazy).
|
||||
const result = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (result) setActivePlugin(record.id);
|
||||
setDetailsRecord(null);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ import {
|
|||
parseSketchWorkspaceDocument,
|
||||
type SketchItem,
|
||||
} from './sketch-model';
|
||||
import { GenerationPreviewStage } from './GenerationPreviewStage';
|
||||
import { buildGenerationPreviewState } from '../runtime/generation-preview';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
|
|
@ -108,6 +111,10 @@ interface Props {
|
|||
githubConnected?: boolean;
|
||||
commentPortalId?: string;
|
||||
onCommentModeChange?: (active: boolean) => void;
|
||||
messages?: ChatMessage[];
|
||||
artifactHtml?: string | null;
|
||||
conversationError?: string | null;
|
||||
onRetry?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
interface SketchState {
|
||||
|
|
@ -226,6 +233,10 @@ export function FileWorkspace({
|
|||
githubConnected,
|
||||
commentPortalId,
|
||||
onCommentModeChange,
|
||||
messages = [],
|
||||
artifactHtml,
|
||||
conversationError,
|
||||
onRetry,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
|
|
@ -270,6 +281,21 @@ export function FileWorkspace({
|
|||
[liveArtifacts],
|
||||
);
|
||||
|
||||
const generationPreview = useMemo(
|
||||
() =>
|
||||
buildGenerationPreviewState({
|
||||
designSystemProject: Boolean(designSystemProject),
|
||||
messages,
|
||||
streaming: Boolean(streaming),
|
||||
activeTab,
|
||||
projectFiles: visibleFiles,
|
||||
liveArtifacts,
|
||||
artifactHtml,
|
||||
conversationError,
|
||||
}),
|
||||
[designSystemProject, messages, streaming, activeTab, visibleFiles, liveArtifacts, artifactHtml, conversationError],
|
||||
);
|
||||
|
||||
// Pull the persisted active tab in when the parent's hydration completes
|
||||
// (or on project switch). Fall back to the Design Files browser so a
|
||||
// fresh project lands in a useful place.
|
||||
|
|
@ -972,6 +998,15 @@ export function FileWorkspace({
|
|||
onConnectRepo={onConnectRepo}
|
||||
githubConnected={githubConnected}
|
||||
/>
|
||||
) : generationPreview ? (
|
||||
<GenerationPreviewStage
|
||||
model={generationPreview}
|
||||
onRetry={
|
||||
generationPreview.retryTarget && onRetry
|
||||
? () => onRetry(generationPreview.retryTarget!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : activeTab === DESIGN_FILES_TAB ? (
|
||||
<DesignFilesPanel
|
||||
key={projectId}
|
||||
|
|
|
|||
281
apps/web/src/components/GenerationPreviewStage.module.css
Normal file
281
apps/web/src/components/GenerationPreviewStage.module.css
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* A gentle breathing halo so the card reads as "alive" while we wait,
|
||||
even between discrete progress events. */
|
||||
.mark[data-active='true'] {
|
||||
animation: markBreathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage[data-phase='failed'] .mark {
|
||||
border-color: color-mix(in srgb, var(--red) 32%, var(--border));
|
||||
background: color-mix(in srgb, var(--red) 8%, var(--bg-panel));
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stage[data-phase='stopped'] .mark {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes markBreathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
min-height: 21px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Live activity snippet: subtly de-emphasised + animated so streaming
|
||||
updates feel continuous rather than abrupt. */
|
||||
.lead[data-live='true'] {
|
||||
color: var(--text-faint);
|
||||
animation: leadFade 240ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes leadFade {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
width: min(360px, 100%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 8px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 24%, transparent));
|
||||
transition: width 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Indeterminate shimmer sweeping over the determinate fill so the bar
|
||||
keeps moving even when the percentage holds steady between events. */
|
||||
.progress[data-active='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--accent) 45%, transparent),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: progressSweep 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage[data-phase='failed'] .progress span {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes progressSweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Dynamic sub-status for the long "generating" step: the concrete task
|
||||
plus a count, refreshed as the agent advances so the middle phase keeps
|
||||
moving without splitting into more (less reliable) discrete steps. */
|
||||
.substatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 420px;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
animation: leadFade 240ms ease-out;
|
||||
}
|
||||
|
||||
.substatusLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.substatusCount {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
/* Steps are revealed one at a time as the agent reaches them, so each
|
||||
pill slides + fades in on mount to make the progression visible. */
|
||||
animation: stepReveal 280ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
@keyframes stepReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.step[data-status='running'] {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step[data-status='succeeded'] {
|
||||
border-color: color-mix(in srgb, var(--green) 36%, var(--border));
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.step[data-status='failed'] {
|
||||
border-color: color-mix(in srgb, var(--red) 40%, var(--border));
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stepIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stepDot[data-running='true'] {
|
||||
opacity: 1;
|
||||
animation: stepPulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stepPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mark[data-active='true'],
|
||||
.progress[data-active='true']::after,
|
||||
.stepDot[data-running='true'],
|
||||
.lead[data-live='true'],
|
||||
.step,
|
||||
.substatus {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-top: 4px;
|
||||
min-height: 34px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast, #fff);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.retry:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.retry:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
121
apps/web/src/components/GenerationPreviewStage.tsx
Normal file
121
apps/web/src/components/GenerationPreviewStage.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useT } from '../i18n';
|
||||
import type { GenerationPreviewModel } from '../runtime/generation-preview';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './GenerationPreviewStage.module.css';
|
||||
|
||||
type Props = {
|
||||
model: GenerationPreviewModel;
|
||||
onRetry?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function GenerationPreviewStage({ model, onRetry }: Props) {
|
||||
const t = useT();
|
||||
|
||||
const generating = model.phase === 'generating';
|
||||
|
||||
const stepLabels: Record<GenerationPreviewModel['steps'][number]['id'], string> = {
|
||||
understand: t('generationPreview.stepUnderstand'),
|
||||
generate: t('generationPreview.stepGenerate'),
|
||||
prepare: t('generationPreview.stepPrepare'),
|
||||
};
|
||||
|
||||
const title =
|
||||
model.phase === 'failed'
|
||||
? t('generationPreview.failedTitle')
|
||||
: model.phase === 'stopped'
|
||||
? t('generationPreview.stoppedTitle')
|
||||
: model.phase === 'awaiting-input'
|
||||
? t('generationPreview.awaitingTitle')
|
||||
: t('generationPreview.title');
|
||||
|
||||
const lead =
|
||||
model.phase === 'failed'
|
||||
? model.errorMessage || t('generationPreview.failedFallback')
|
||||
: model.phase === 'stopped'
|
||||
? t('generationPreview.stoppedLead')
|
||||
: model.phase === 'awaiting-input'
|
||||
? t('generationPreview.awaitingLead')
|
||||
: model.activityLabel;
|
||||
|
||||
const markIcon =
|
||||
model.phase === 'failed' ? 'close' : model.phase === 'stopped' ? 'stop' : 'sparkles';
|
||||
|
||||
// Once concrete sub-status (current task + count) is available we let it
|
||||
// carry the live signal and drop the higher-level narration line, so only
|
||||
// one dynamic line shows at a time.
|
||||
const showSubstatus = generating && Boolean(model.detailLabel || model.todoProgress);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={styles.stage}
|
||||
data-testid="generation-preview-stage"
|
||||
data-phase={model.phase}
|
||||
aria-live="polite"
|
||||
aria-busy={generating}
|
||||
>
|
||||
<div className={styles.mark} data-active={generating} aria-hidden>
|
||||
<Icon name={markIcon} size={24} />
|
||||
</div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{!showSubstatus && lead ? (
|
||||
<p className={styles.lead} data-live={generating && Boolean(model.activityLabel)}>
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={styles.progress}
|
||||
data-active={generating}
|
||||
role="progressbar"
|
||||
aria-label={t('generationPreview.progressAria', { percent: model.progressPercent })}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={model.progressPercent}
|
||||
>
|
||||
<span style={{ width: `${model.progressPercent}%` }} />
|
||||
</div>
|
||||
<ol className={styles.steps}>
|
||||
{model.steps
|
||||
.filter((step) => step.status !== 'pending')
|
||||
.map((step) => (
|
||||
<li key={step.id} className={styles.step} data-status={step.status}>
|
||||
<span className={styles.stepIcon} aria-hidden>
|
||||
{step.status === 'succeeded' ? (
|
||||
<Icon name="check" size={12} />
|
||||
) : step.status === 'failed' ? (
|
||||
<Icon name="close" size={12} />
|
||||
) : (
|
||||
<span className={styles.stepDot} data-running={step.status === 'running' && generating} />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.stepLabel}>{stepLabels[step.id]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{generating && (model.detailLabel || model.todoProgress) ? (
|
||||
<div
|
||||
key={`${model.detailLabel ?? ''}-${model.todoProgress?.done ?? ''}`}
|
||||
className={styles.substatus}
|
||||
>
|
||||
{model.detailLabel ? (
|
||||
<span className={styles.substatusLabel}>{model.detailLabel}</span>
|
||||
) : null}
|
||||
{model.todoProgress ? (
|
||||
<span className={styles.substatusCount}>
|
||||
{model.todoProgress.done}/{model.todoProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{model.phase === 'failed' && onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retry}
|
||||
data-testid="generation-preview-retry"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('generationPreview.retry')}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -4580,6 +4580,10 @@ export function ProjectView({
|
|||
githubConnected={githubConnected}
|
||||
commentPortalId={commentInspectorPortalId}
|
||||
onCommentModeChange={setCommentInspectorActive}
|
||||
messages={messages}
|
||||
artifactHtml={artifact?.html}
|
||||
conversationError={error}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</div>
|
||||
{projectActionsToast ? (
|
||||
|
|
|
|||
|
|
@ -1799,4 +1799,19 @@ export const ar: Dict = {
|
|||
'diagnostics.exporting': 'جارٍ التصدير…',
|
||||
'diagnostics.exportSuccess': 'تم حفظ التشخيص في {path}',
|
||||
'diagnostics.exportFailed': 'تعذّر تصدير التشخيص: {message}',
|
||||
'generationPreview.title': 'جارٍ الإنشاء…',
|
||||
'generationPreview.failedTitle': 'فشل الإنشاء',
|
||||
'generationPreview.failedFallback': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
'generationPreview.footnote': 'يستغرق عادةً من 2 إلى 5 دقائق',
|
||||
'generationPreview.stepUnderstand': 'فهم المتطلبات',
|
||||
'generationPreview.stepGenerate': 'إنشاء الصفحة',
|
||||
'generationPreview.stepPrepare': 'تحضير المعاينة',
|
||||
'generationPreview.elapsed': 'مضى {elapsed}',
|
||||
'generationPreview.estimate': 'عادةً 2–5 دقائق',
|
||||
'generationPreview.progressAria': 'تقدّم الإنشاء: {percent}%',
|
||||
'generationPreview.retry': 'إعادة المحاولة',
|
||||
'generationPreview.awaitingTitle': 'في انتظار ردّك',
|
||||
'generationPreview.awaitingLead': 'أجب عن بعض الأسئلة في المحادثة للمتابعة.',
|
||||
'generationPreview.stoppedTitle': 'تم إيقاف الإنشاء مؤقتًا',
|
||||
'generationPreview.stoppedLead': 'تابع الخطوات المتبقية من المحادثة على اليسار.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1736,4 +1736,19 @@ export const de: Dict = {
|
|||
'diagnostics.exporting': 'Exportiere…',
|
||||
'diagnostics.exportSuccess': 'Diagnose gespeichert: {path}',
|
||||
'diagnostics.exportFailed': 'Diagnose-Export fehlgeschlagen: {message}',
|
||||
'generationPreview.title': 'Wird generiert…',
|
||||
'generationPreview.failedTitle': 'Generierung fehlgeschlagen',
|
||||
'generationPreview.failedFallback': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
|
||||
'generationPreview.footnote': 'Dauert normalerweise 2–5 Minuten',
|
||||
'generationPreview.stepUnderstand': 'Anforderungen verstehen',
|
||||
'generationPreview.stepGenerate': 'Seite generieren',
|
||||
'generationPreview.stepPrepare': 'Vorschau vorbereiten',
|
||||
'generationPreview.elapsed': '{elapsed} vergangen',
|
||||
'generationPreview.estimate': 'Normalerweise 2–5 Min.',
|
||||
'generationPreview.progressAria': 'Generierungsfortschritt: {percent}%',
|
||||
'generationPreview.retry': 'Erneut versuchen',
|
||||
'generationPreview.awaitingTitle': 'Warten auf deine Eingabe',
|
||||
'generationPreview.awaitingLead': 'Beantworte ein paar kurze Fragen im Chat, um fortzufahren.',
|
||||
'generationPreview.stoppedTitle': 'Generierung pausiert',
|
||||
'generationPreview.stoppedLead': 'Setze die verbleibenden Schritte im Chat links fort.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,21 @@ export const en: Dict = {
|
|||
'workspace.openFromDesignFiles': 'Open a file from',
|
||||
'workspace.designFilesLink': 'Design Files',
|
||||
'workspace.loadingSketch': 'Loading sketch…',
|
||||
'generationPreview.title': 'Generating…',
|
||||
'generationPreview.failedTitle': 'Generation failed',
|
||||
'generationPreview.failedFallback': 'Something went wrong. Please try again.',
|
||||
'generationPreview.footnote': 'Usually takes 2–5 minutes',
|
||||
'generationPreview.stepUnderstand': 'Understanding requirements',
|
||||
'generationPreview.stepGenerate': 'Generating page',
|
||||
'generationPreview.stepPrepare': 'Preparing preview',
|
||||
'generationPreview.elapsed': '{elapsed} elapsed',
|
||||
'generationPreview.estimate': 'Usually 2–5 min',
|
||||
'generationPreview.progressAria': 'Generation progress: {percent}%',
|
||||
'generationPreview.retry': 'Retry',
|
||||
'generationPreview.awaitingTitle': 'Waiting for your input',
|
||||
'generationPreview.awaitingLead': 'Answer a few quick questions in the chat to continue.',
|
||||
'generationPreview.stoppedTitle': 'Generation paused',
|
||||
'generationPreview.stoppedLead': 'Resume the remaining steps from the chat on the left.',
|
||||
'designFiles.title': 'Design Files',
|
||||
'designFiles.upload': 'Upload files',
|
||||
'designFiles.pasteText': 'Paste as text file',
|
||||
|
|
|
|||
|
|
@ -1687,4 +1687,19 @@ export const esES: Dict = {
|
|||
'diagnostics.exporting': 'Exportando…',
|
||||
'diagnostics.exportSuccess': 'Diagnósticos guardados en {path}',
|
||||
'diagnostics.exportFailed': 'No se pudieron exportar los diagnósticos: {message}',
|
||||
'generationPreview.title': 'Generando…',
|
||||
'generationPreview.failedTitle': 'Error de generación',
|
||||
'generationPreview.failedFallback': 'Algo salió mal. Inténtalo de nuevo.',
|
||||
'generationPreview.footnote': 'Suele tardar de 2 a 5 minutos',
|
||||
'generationPreview.stepUnderstand': 'Entendiendo los requisitos',
|
||||
'generationPreview.stepGenerate': 'Generando la página',
|
||||
'generationPreview.stepPrepare': 'Preparando la vista previa',
|
||||
'generationPreview.elapsed': '{elapsed} transcurridos',
|
||||
'generationPreview.estimate': 'Normalmente 2–5 min',
|
||||
'generationPreview.progressAria': 'Progreso de la generación: {percent}%',
|
||||
'generationPreview.retry': 'Reintentar',
|
||||
'generationPreview.awaitingTitle': 'Esperando tu respuesta',
|
||||
'generationPreview.awaitingLead': 'Responde unas preguntas en el chat para continuar.',
|
||||
'generationPreview.stoppedTitle': 'Generación en pausa',
|
||||
'generationPreview.stoppedLead': 'Reanuda los pasos restantes desde el chat de la izquierda.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1841,4 +1841,19 @@ export const fa: Dict = {
|
|||
'diagnostics.exporting': 'در حال صادر کردن…',
|
||||
'diagnostics.exportSuccess': 'تشخیص در {path} ذخیره شد',
|
||||
'diagnostics.exportFailed': 'صادر کردن تشخیص ناموفق بود: {message}',
|
||||
'generationPreview.title': 'در حال ساخت…',
|
||||
'generationPreview.failedTitle': 'ساخت ناموفق بود',
|
||||
'generationPreview.failedFallback': 'مشکلی پیش آمد. لطفاً دوباره تلاش کنید.',
|
||||
'generationPreview.footnote': 'معمولاً ۲ تا ۵ دقیقه طول میکشد',
|
||||
'generationPreview.stepUnderstand': 'درک نیازمندیها',
|
||||
'generationPreview.stepGenerate': 'ساخت صفحه',
|
||||
'generationPreview.stepPrepare': 'آمادهسازی پیشنمایش',
|
||||
'generationPreview.elapsed': '{elapsed} گذشته',
|
||||
'generationPreview.estimate': 'معمولاً ۲ تا ۵ دقیقه',
|
||||
'generationPreview.progressAria': 'پیشرفت ساخت: {percent}%',
|
||||
'generationPreview.retry': 'تلاش دوباره',
|
||||
'generationPreview.awaitingTitle': 'در انتظار پاسخ شما',
|
||||
'generationPreview.awaitingLead': 'برای ادامه، به چند پرسش در گفتگو پاسخ دهید.',
|
||||
'generationPreview.stoppedTitle': 'ساخت متوقف شد',
|
||||
'generationPreview.stoppedLead': 'مراحل باقیمانده را از گفتگوی سمت چپ ادامه دهید.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2448,4 +2448,19 @@ export const fr: Dict = {
|
|||
'diagnostics.exporting': 'Exportation…',
|
||||
'diagnostics.exportSuccess': 'Diagnostic enregistré dans {path}',
|
||||
'diagnostics.exportFailed': 'Impossible d\'exporter le diagnostic: {message}',
|
||||
'generationPreview.title': 'Génération…',
|
||||
'generationPreview.failedTitle': 'Échec de la génération',
|
||||
'generationPreview.failedFallback': 'Une erreur est survenue. Veuillez réessayer.',
|
||||
'generationPreview.footnote': 'Prend généralement 2 à 5 minutes',
|
||||
'generationPreview.stepUnderstand': 'Compréhension des besoins',
|
||||
'generationPreview.stepGenerate': 'Génération de la page',
|
||||
'generationPreview.stepPrepare': 'Préparation de l\'aperçu',
|
||||
'generationPreview.elapsed': '{elapsed} écoulées',
|
||||
'generationPreview.estimate': 'Généralement 2–5 min',
|
||||
'generationPreview.progressAria': 'Progression de la génération : {percent}%',
|
||||
'generationPreview.retry': 'Réessayer',
|
||||
'generationPreview.awaitingTitle': 'En attente de votre réponse',
|
||||
'generationPreview.awaitingLead': 'Répondez à quelques questions dans le chat pour continuer.',
|
||||
'generationPreview.stoppedTitle': 'Génération en pause',
|
||||
'generationPreview.stoppedLead': 'Reprenez les étapes restantes depuis le chat à gauche.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1808,4 +1808,19 @@ export const hu: Dict = {
|
|||
'diagnostics.exporting': 'Exportálás…',
|
||||
'diagnostics.exportSuccess': 'Diagnosztika mentve: {path}',
|
||||
'diagnostics.exportFailed': 'Diagnosztika exportálása sikertelen: {message}',
|
||||
'generationPreview.title': 'Generálás…',
|
||||
'generationPreview.failedTitle': 'A generálás sikertelen',
|
||||
'generationPreview.failedFallback': 'Hiba történt. Kérjük, próbáld újra.',
|
||||
'generationPreview.footnote': 'Általában 2–5 percet vesz igénybe',
|
||||
'generationPreview.stepUnderstand': 'Követelmények értelmezése',
|
||||
'generationPreview.stepGenerate': 'Oldal generálása',
|
||||
'generationPreview.stepPrepare': 'Előnézet előkészítése',
|
||||
'generationPreview.elapsed': '{elapsed} eltelt',
|
||||
'generationPreview.estimate': 'Általában 2–5 perc',
|
||||
'generationPreview.progressAria': 'Generálás állapota: {percent}%',
|
||||
'generationPreview.retry': 'Újra',
|
||||
'generationPreview.awaitingTitle': 'Várakozás a válaszodra',
|
||||
'generationPreview.awaitingLead': 'Válaszolj néhány kérdésre a csevegésben a folytatáshoz.',
|
||||
'generationPreview.stoppedTitle': 'Generálás szüneteltetve',
|
||||
'generationPreview.stoppedLead': 'Folytasd a hátralévő lépéseket a bal oldali csevegésből.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1841,4 +1841,19 @@ export const id: Dict = {
|
|||
'diagnostics.exporting': 'Mengekspor…',
|
||||
'diagnostics.exportSuccess': 'Diagnostik disimpan di {path}',
|
||||
'diagnostics.exportFailed': 'Gagal mengekspor diagnostik: {message}',
|
||||
'generationPreview.title': 'Membuat…',
|
||||
'generationPreview.failedTitle': 'Pembuatan gagal',
|
||||
'generationPreview.failedFallback': 'Terjadi kesalahan. Silakan coba lagi.',
|
||||
'generationPreview.footnote': 'Biasanya butuh 2–5 menit',
|
||||
'generationPreview.stepUnderstand': 'Memahami kebutuhan',
|
||||
'generationPreview.stepGenerate': 'Membuat halaman',
|
||||
'generationPreview.stepPrepare': 'Menyiapkan pratinjau',
|
||||
'generationPreview.elapsed': '{elapsed} berlalu',
|
||||
'generationPreview.estimate': 'Biasanya 2–5 mnt',
|
||||
'generationPreview.progressAria': 'Progres pembuatan: {percent}%',
|
||||
'generationPreview.retry': 'Coba lagi',
|
||||
'generationPreview.awaitingTitle': 'Menunggu masukan Anda',
|
||||
'generationPreview.awaitingLead': 'Jawab beberapa pertanyaan di obrolan untuk melanjutkan.',
|
||||
'generationPreview.stoppedTitle': 'Pembuatan dijeda',
|
||||
'generationPreview.stoppedLead': 'Lanjutkan langkah yang tersisa dari obrolan di kiri.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1667,4 +1667,19 @@ export const it: Dict = {
|
|||
'liveArtifact.viewer.code.loading': 'Caricamento codice…',
|
||||
'liveArtifact.viewer.code.unavailable': 'Il codice non è ancora disponibile.',
|
||||
'liveArtifact.viewer.code.empty': 'Questo file di codice è vuoto.',
|
||||
'generationPreview.title': 'Generazione…',
|
||||
'generationPreview.failedTitle': 'Generazione non riuscita',
|
||||
'generationPreview.failedFallback': 'Qualcosa è andato storto. Riprova.',
|
||||
'generationPreview.footnote': 'Di solito richiede 2–5 minuti',
|
||||
'generationPreview.stepUnderstand': 'Analisi dei requisiti',
|
||||
'generationPreview.stepGenerate': 'Generazione della pagina',
|
||||
'generationPreview.stepPrepare': 'Preparazione dell\'anteprima',
|
||||
'generationPreview.elapsed': '{elapsed} trascorsi',
|
||||
'generationPreview.estimate': 'Di solito 2–5 min',
|
||||
'generationPreview.progressAria': 'Avanzamento della generazione: {percent}%',
|
||||
'generationPreview.retry': 'Riprova',
|
||||
'generationPreview.awaitingTitle': 'In attesa della tua risposta',
|
||||
'generationPreview.awaitingLead': 'Rispondi ad alcune domande nella chat per continuare.',
|
||||
'generationPreview.stoppedTitle': 'Generazione in pausa',
|
||||
'generationPreview.stoppedLead': 'Riprendi i passaggi rimanenti dalla chat a sinistra.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1735,4 +1735,19 @@ export const ja: Dict = {
|
|||
'diagnostics.exporting': 'エクスポート中…',
|
||||
'diagnostics.exportSuccess': '診断情報を {path} に保存しました',
|
||||
'diagnostics.exportFailed': '診断情報のエクスポートに失敗しました: {message}',
|
||||
'generationPreview.title': '生成中…',
|
||||
'generationPreview.failedTitle': '生成に失敗しました',
|
||||
'generationPreview.failedFallback': '問題が発生しました。もう一度お試しください。',
|
||||
'generationPreview.footnote': '通常2〜5分かかります',
|
||||
'generationPreview.stepUnderstand': '要件を理解中',
|
||||
'generationPreview.stepGenerate': 'ページを生成中',
|
||||
'generationPreview.stepPrepare': 'プレビューを準備中',
|
||||
'generationPreview.elapsed': '経過 {elapsed}',
|
||||
'generationPreview.estimate': '通常2〜5分',
|
||||
'generationPreview.progressAria': '生成の進捗:{percent}%',
|
||||
'generationPreview.retry': '再試行',
|
||||
'generationPreview.awaitingTitle': '入力をお待ちしています',
|
||||
'generationPreview.awaitingLead': 'チャットでいくつかの質問に答えると続行します。',
|
||||
'generationPreview.stoppedTitle': '生成を一時停止しました',
|
||||
'generationPreview.stoppedLead': '左側のチャットから残りのステップを再開できます。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1848,4 +1848,19 @@ export const ko: Dict = {
|
|||
'diagnostics.exporting': '내보내는 중…',
|
||||
'diagnostics.exportSuccess': '진단 정보를 {path}에 저장했습니다',
|
||||
'diagnostics.exportFailed': '진단 정보 내보내기 실패: {message}',
|
||||
'generationPreview.title': '생성 중…',
|
||||
'generationPreview.failedTitle': '생성 실패',
|
||||
'generationPreview.failedFallback': '문제가 발생했습니다. 다시 시도해 주세요.',
|
||||
'generationPreview.footnote': '보통 2~5분 정도 걸립니다',
|
||||
'generationPreview.stepUnderstand': '요구사항 이해 중',
|
||||
'generationPreview.stepGenerate': '페이지 생성 중',
|
||||
'generationPreview.stepPrepare': '미리보기 준비 중',
|
||||
'generationPreview.elapsed': '경과 {elapsed}',
|
||||
'generationPreview.estimate': '보통 2~5분',
|
||||
'generationPreview.progressAria': '생성 진행률: {percent}%',
|
||||
'generationPreview.retry': '다시 시도',
|
||||
'generationPreview.awaitingTitle': '입력을 기다리는 중',
|
||||
'generationPreview.awaitingLead': '왼쪽 채팅에서 몇 가지 질문에 답하면 계속됩니다.',
|
||||
'generationPreview.stoppedTitle': '생성이 일시중지됨',
|
||||
'generationPreview.stoppedLead': '왼쪽 채팅에서 남은 단계를 다시 시작할 수 있습니다.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1798,4 +1798,19 @@ export const pl: Dict = {
|
|||
'diagnostics.exporting': 'Eksportowanie…',
|
||||
'diagnostics.exportSuccess': 'Diagnostyka zapisana w {path}',
|
||||
'diagnostics.exportFailed': 'Nie udało się wyeksportować diagnostyki: {message}',
|
||||
'generationPreview.title': 'Generowanie…',
|
||||
'generationPreview.failedTitle': 'Generowanie nie powiodło się',
|
||||
'generationPreview.failedFallback': 'Coś poszło nie tak. Spróbuj ponownie.',
|
||||
'generationPreview.footnote': 'Zwykle trwa 2–5 minut',
|
||||
'generationPreview.stepUnderstand': 'Analiza wymagań',
|
||||
'generationPreview.stepGenerate': 'Generowanie strony',
|
||||
'generationPreview.stepPrepare': 'Przygotowanie podglądu',
|
||||
'generationPreview.elapsed': 'Upłynęło {elapsed}',
|
||||
'generationPreview.estimate': 'Zwykle 2–5 min',
|
||||
'generationPreview.progressAria': 'Postęp generowania: {percent}%',
|
||||
'generationPreview.retry': 'Spróbuj ponownie',
|
||||
'generationPreview.awaitingTitle': 'Oczekiwanie na Twoją odpowiedź',
|
||||
'generationPreview.awaitingLead': 'Odpowiedz na kilka pytań na czacie, aby kontynuować.',
|
||||
'generationPreview.stoppedTitle': 'Generowanie wstrzymane',
|
||||
'generationPreview.stoppedLead': 'Wznów pozostałe kroki z czatu po lewej stronie.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1839,4 +1839,19 @@ export const ptBR: Dict = {
|
|||
'diagnostics.exporting': 'Exportando…',
|
||||
'diagnostics.exportSuccess': 'Diagnósticos salvos em {path}',
|
||||
'diagnostics.exportFailed': 'Falha ao exportar diagnósticos: {message}',
|
||||
'generationPreview.title': 'Gerando…',
|
||||
'generationPreview.failedTitle': 'Falha na geração',
|
||||
'generationPreview.failedFallback': 'Algo deu errado. Tente novamente.',
|
||||
'generationPreview.footnote': 'Normalmente leva de 2 a 5 minutos',
|
||||
'generationPreview.stepUnderstand': 'Entendendo os requisitos',
|
||||
'generationPreview.stepGenerate': 'Gerando a página',
|
||||
'generationPreview.stepPrepare': 'Preparando a prévia',
|
||||
'generationPreview.elapsed': '{elapsed} decorridos',
|
||||
'generationPreview.estimate': 'Normalmente 2–5 min',
|
||||
'generationPreview.progressAria': 'Progresso da geração: {percent}%',
|
||||
'generationPreview.retry': 'Tentar novamente',
|
||||
'generationPreview.awaitingTitle': 'Aguardando sua resposta',
|
||||
'generationPreview.awaitingLead': 'Responda algumas perguntas no chat para continuar.',
|
||||
'generationPreview.stoppedTitle': 'Geração pausada',
|
||||
'generationPreview.stoppedLead': 'Retome as etapas restantes pelo chat à esquerda.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1839,4 +1839,19 @@ export const ru: Dict = {
|
|||
'diagnostics.exporting': 'Экспортирование…',
|
||||
'diagnostics.exportSuccess': 'Диагностика сохранена: {path}',
|
||||
'diagnostics.exportFailed': 'Не удалось экспортировать диагностику: {message}',
|
||||
'generationPreview.title': 'Генерация…',
|
||||
'generationPreview.failedTitle': 'Ошибка генерации',
|
||||
'generationPreview.failedFallback': 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||
'generationPreview.footnote': 'Обычно занимает 2–5 минут',
|
||||
'generationPreview.stepUnderstand': 'Анализ требований',
|
||||
'generationPreview.stepGenerate': 'Создание страницы',
|
||||
'generationPreview.stepPrepare': 'Подготовка предпросмотра',
|
||||
'generationPreview.elapsed': 'Прошло {elapsed}',
|
||||
'generationPreview.estimate': 'Обычно 2–5 мин',
|
||||
'generationPreview.progressAria': 'Прогресс генерации: {percent}%',
|
||||
'generationPreview.retry': 'Повторить',
|
||||
'generationPreview.awaitingTitle': 'Ожидание вашего ответа',
|
||||
'generationPreview.awaitingLead': 'Ответьте на несколько вопросов в чате, чтобы продолжить.',
|
||||
'generationPreview.stoppedTitle': 'Генерация приостановлена',
|
||||
'generationPreview.stoppedLead': 'Продолжите оставшиеся шаги в чате слева.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1628,4 +1628,19 @@ export const th: Dict = {
|
|||
'settings.designSystemsCategory': 'หมวดหมู่',
|
||||
'settings.designSystemsAllCategories': 'ทุกหมวดหมู่',
|
||||
'settings.designSystemsShowInHomeGallery': 'แสดงในแกลเลอรีหน้าแรก',
|
||||
'generationPreview.title': 'กำลังสร้าง…',
|
||||
'generationPreview.failedTitle': 'การสร้างล้มเหลว',
|
||||
'generationPreview.failedFallback': 'เกิดข้อผิดพลาด โปรดลองอีกครั้ง',
|
||||
'generationPreview.footnote': 'โดยปกติใช้เวลา 2–5 นาที',
|
||||
'generationPreview.stepUnderstand': 'กำลังทำความเข้าใจความต้องการ',
|
||||
'generationPreview.stepGenerate': 'กำลังสร้างหน้า',
|
||||
'generationPreview.stepPrepare': 'กำลังเตรียมตัวอย่าง',
|
||||
'generationPreview.elapsed': 'ผ่านไป {elapsed}',
|
||||
'generationPreview.estimate': 'โดยปกติ 2–5 นาที',
|
||||
'generationPreview.progressAria': 'ความคืบหน้าการสร้าง: {percent}%',
|
||||
'generationPreview.retry': 'ลองใหม่',
|
||||
'generationPreview.awaitingTitle': 'กำลังรอข้อมูลจากคุณ',
|
||||
'generationPreview.awaitingLead': 'ตอบคำถามสองสามข้อในแชทเพื่อดำเนินการต่อ',
|
||||
'generationPreview.stoppedTitle': 'หยุดการสร้างชั่วคราว',
|
||||
'generationPreview.stoppedLead': 'ดำเนินการขั้นตอนที่เหลือต่อจากแชทด้านซ้าย',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1785,4 +1785,19 @@ export const tr: Dict = {
|
|||
'diagnostics.exporting': 'Dışa aktarılıyor…',
|
||||
'diagnostics.exportSuccess': 'Tanılama {path} konumuna kaydedildi',
|
||||
'diagnostics.exportFailed': 'Tanılama dışa aktarılamadı: {message}',
|
||||
'generationPreview.title': 'Oluşturuluyor…',
|
||||
'generationPreview.failedTitle': 'Oluşturma başarısız',
|
||||
'generationPreview.failedFallback': 'Bir şeyler ters gitti. Lütfen tekrar deneyin.',
|
||||
'generationPreview.footnote': 'Genellikle 2–5 dakika sürer',
|
||||
'generationPreview.stepUnderstand': 'Gereksinimler anlaşılıyor',
|
||||
'generationPreview.stepGenerate': 'Sayfa oluşturuluyor',
|
||||
'generationPreview.stepPrepare': 'Önizleme hazırlanıyor',
|
||||
'generationPreview.elapsed': '{elapsed} geçti',
|
||||
'generationPreview.estimate': 'Genellikle 2–5 dk',
|
||||
'generationPreview.progressAria': 'Oluşturma ilerlemesi: %{percent}',
|
||||
'generationPreview.retry': 'Yeniden dene',
|
||||
'generationPreview.awaitingTitle': 'Yanıtınız bekleniyor',
|
||||
'generationPreview.awaitingLead': 'Devam etmek için sohbette birkaç soruyu yanıtlayın.',
|
||||
'generationPreview.stoppedTitle': 'Oluşturma duraklatıldı',
|
||||
'generationPreview.stoppedLead': 'Kalan adımları soldaki sohbetten sürdürün.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1841,4 +1841,19 @@ export const uk: Dict = {
|
|||
'diagnostics.exporting': 'Експортування…',
|
||||
'diagnostics.exportSuccess': 'Діагностику збережено: {path}',
|
||||
'diagnostics.exportFailed': 'Не вдалося експортувати діагностику: {message}',
|
||||
'generationPreview.title': 'Генерація…',
|
||||
'generationPreview.failedTitle': 'Помилка генерації',
|
||||
'generationPreview.failedFallback': 'Щось пішло не так. Спробуйте ще раз.',
|
||||
'generationPreview.footnote': 'Зазвичай триває 2–5 хвилин',
|
||||
'generationPreview.stepUnderstand': 'Аналіз вимог',
|
||||
'generationPreview.stepGenerate': 'Створення сторінки',
|
||||
'generationPreview.stepPrepare': 'Підготовка попереднього перегляду',
|
||||
'generationPreview.elapsed': 'Минуло {elapsed}',
|
||||
'generationPreview.estimate': 'Зазвичай 2–5 хв',
|
||||
'generationPreview.progressAria': 'Прогрес генерації: {percent}%',
|
||||
'generationPreview.retry': 'Повторити',
|
||||
'generationPreview.awaitingTitle': 'Очікування вашої відповіді',
|
||||
'generationPreview.awaitingLead': 'Дайте відповідь на кілька запитань у чаті, щоб продовжити.',
|
||||
'generationPreview.stoppedTitle': 'Генерацію призупинено',
|
||||
'generationPreview.stoppedLead': 'Продовжте решту кроків у чаті ліворуч.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2550,4 +2550,19 @@ export const zhCN: Dict = {
|
|||
'diagnostics.exporting': '导出中…',
|
||||
'diagnostics.exportSuccess': '诊断日志已保存到 {path}',
|
||||
'diagnostics.exportFailed': '导出诊断日志失败:{message}',
|
||||
'generationPreview.title': '正在生成…',
|
||||
'generationPreview.failedTitle': '生成失败',
|
||||
'generationPreview.failedFallback': '出现错误,请重试。',
|
||||
'generationPreview.footnote': '通常需要 2–5 分钟',
|
||||
'generationPreview.stepUnderstand': '理解需求',
|
||||
'generationPreview.stepGenerate': '生成页面',
|
||||
'generationPreview.stepPrepare': '准备预览',
|
||||
'generationPreview.elapsed': '已等待 {elapsed}',
|
||||
'generationPreview.estimate': '通常需要 2–5 分钟',
|
||||
'generationPreview.progressAria': '生成进度:{percent}%',
|
||||
'generationPreview.retry': '重试',
|
||||
'generationPreview.awaitingTitle': '等待你的补充',
|
||||
'generationPreview.awaitingLead': '在左侧聊天里回答几个问题即可继续生成。',
|
||||
'generationPreview.stoppedTitle': '生成已暂停',
|
||||
'generationPreview.stoppedLead': '可在左侧聊天里继续未完成的步骤。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2118,4 +2118,19 @@ export const zhTW: Dict = {
|
|||
'skillPluginCandidate.publishRepo': '發布倉庫',
|
||||
'skillPluginCandidate.dismiss': '忽略',
|
||||
'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。',
|
||||
'generationPreview.title': '正在生成…',
|
||||
'generationPreview.failedTitle': '生成失敗',
|
||||
'generationPreview.failedFallback': '發生錯誤,請重試。',
|
||||
'generationPreview.footnote': '通常需要 2–5 分鐘',
|
||||
'generationPreview.stepUnderstand': '理解需求',
|
||||
'generationPreview.stepGenerate': '生成頁面',
|
||||
'generationPreview.stepPrepare': '準備預覽',
|
||||
'generationPreview.elapsed': '已等待 {elapsed}',
|
||||
'generationPreview.estimate': '通常需要 2–5 分鐘',
|
||||
'generationPreview.progressAria': '生成進度:{percent}%',
|
||||
'generationPreview.retry': '重試',
|
||||
'generationPreview.awaitingTitle': '等待你的補充',
|
||||
'generationPreview.awaitingLead': '在左側聊天裡回答幾個問題即可繼續生成。',
|
||||
'generationPreview.stoppedTitle': '生成已暫停',
|
||||
'generationPreview.stoppedLead': '可在左側聊天裡繼續未完成的步驟。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1897,6 +1897,21 @@ export interface Dict {
|
|||
'workspace.openFromDesignFiles': string;
|
||||
'workspace.designFilesLink': string;
|
||||
'workspace.loadingSketch': string;
|
||||
'generationPreview.title': string;
|
||||
'generationPreview.failedTitle': string;
|
||||
'generationPreview.failedFallback': string;
|
||||
'generationPreview.footnote': string;
|
||||
'generationPreview.stepUnderstand': string;
|
||||
'generationPreview.stepGenerate': string;
|
||||
'generationPreview.stepPrepare': string;
|
||||
'generationPreview.elapsed': string;
|
||||
'generationPreview.estimate': string;
|
||||
'generationPreview.progressAria': string;
|
||||
'generationPreview.retry': string;
|
||||
'generationPreview.awaitingTitle': string;
|
||||
'generationPreview.awaitingLead': string;
|
||||
'generationPreview.stoppedTitle': string;
|
||||
'generationPreview.stoppedLead': string;
|
||||
'designFiles.title': string;
|
||||
'designFiles.upload': string;
|
||||
'designFiles.pasteText': string;
|
||||
|
|
|
|||
335
apps/web/src/runtime/generation-preview.ts
Normal file
335
apps/web/src/runtime/generation-preview.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import type { AgentEvent, ChatMessage, LiveArtifactSummary, ProjectFile } from '../types';
|
||||
import { isLiveArtifactTabId, liveArtifactTabId } from '../types';
|
||||
import { isTodoWriteToolName, latestTodosFromEvents, type TodoItem } from './todos';
|
||||
|
||||
export type GenerationStepStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export type GenerationPhase = 'generating' | 'awaiting-input' | 'stopped' | 'failed';
|
||||
|
||||
export interface GenerationPreviewStep {
|
||||
id: 'understand' | 'generate' | 'prepare';
|
||||
status: GenerationStepStatus;
|
||||
}
|
||||
|
||||
export interface GenerationPreviewModel {
|
||||
startedAt: number;
|
||||
steps: GenerationPreviewStep[];
|
||||
phase: GenerationPhase;
|
||||
failed: boolean;
|
||||
errorMessage: string | null;
|
||||
progressPercent: number;
|
||||
/**
|
||||
* Latest human-readable activity snippet pulled from the streamed
|
||||
* events. Only set while actively generating so the waiting surface
|
||||
* shows real movement instead of a frozen card.
|
||||
*/
|
||||
activityLabel: string | null;
|
||||
/**
|
||||
* Concrete sub-status for the long "generating" phase, e.g. the
|
||||
* in-progress task ("Writing index.html") or the current write target.
|
||||
* Lets the middle step show movement without splitting into more
|
||||
* (less reliable) discrete steps. Only set while generating.
|
||||
*/
|
||||
detailLabel: string | null;
|
||||
/**
|
||||
* Task counter derived from the agent's TodoWrite plan, e.g. 3/8. The
|
||||
* in-progress task counts toward `done` to match the chat-side todo card.
|
||||
* Only set while generating and when the agent emitted a plan.
|
||||
*/
|
||||
todoProgress: { done: number; total: number } | null;
|
||||
}
|
||||
|
||||
// Matches the inline forms the agent emits to ask the user clarifying
|
||||
// questions before continuing (see artifacts/question-form.ts).
|
||||
const QUESTION_FORM_RE = /<(question-form|ask-question)\b/i;
|
||||
|
||||
// Tools that represent concrete generation work (writing/editing files,
|
||||
// running commands) as opposed to reads/plans.
|
||||
const WRITE_LIKE_TOOL_RE = /^(write|edit|multiedit|bash|run_terminal_cmd)$/i;
|
||||
|
||||
const PREVIEWABLE_FILE = /\.(html?|jsx|tsx|svg|md|pdf|pptx?|key)$/i;
|
||||
|
||||
export function workspaceHasPreviewSurface(input: {
|
||||
activeTab: string | null;
|
||||
projectFiles: ProjectFile[];
|
||||
liveArtifacts: LiveArtifactSummary[];
|
||||
streamingArtifactHtml?: string | null | undefined;
|
||||
}): boolean {
|
||||
if (input.streamingArtifactHtml?.trim()) return true;
|
||||
const active = input.activeTab;
|
||||
if (!active) return false;
|
||||
if (isLiveArtifactTabId(active)) {
|
||||
return input.liveArtifacts.some((entry) => liveArtifactTabId(entry.id) === active);
|
||||
}
|
||||
const file = input.projectFiles.find((item) => item.name === active);
|
||||
if (!file) return false;
|
||||
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio' || file.kind === 'sketch') {
|
||||
return true;
|
||||
}
|
||||
if (PREVIEWABLE_FILE.test(file.name)) return true;
|
||||
return file.kind === 'html' || file.kind === 'code' || file.kind === 'text';
|
||||
}
|
||||
|
||||
export function deriveGenerationPreviewModel(input: {
|
||||
events: AgentEvent[];
|
||||
hasArtifactHtml: boolean;
|
||||
hasPreviewSurface: boolean;
|
||||
failed: boolean;
|
||||
errorMessage?: string | null;
|
||||
}): Pick<GenerationPreviewModel, 'steps' | 'progressPercent' | 'errorMessage'> {
|
||||
const steps = derivePrototypeGenerationSteps({
|
||||
events: input.events,
|
||||
hasArtifactHtml: input.hasArtifactHtml,
|
||||
hasPreviewSurface: input.hasPreviewSurface,
|
||||
failed: input.failed,
|
||||
});
|
||||
const progressPercent = generationPreviewProgress(steps);
|
||||
return {
|
||||
steps,
|
||||
progressPercent,
|
||||
errorMessage: input.failed ? input.errorMessage?.trim() || failureMessageFromEvents(input.events) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGenerationPreviewState(input: {
|
||||
designSystemProject: boolean;
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
activeTab: string | null;
|
||||
projectFiles: ProjectFile[];
|
||||
liveArtifacts: LiveArtifactSummary[];
|
||||
artifactHtml?: string | null;
|
||||
conversationError?: string | null;
|
||||
}): (GenerationPreviewModel & { retryTarget: ChatMessage | null }) | null {
|
||||
if (input.designSystemProject) return null;
|
||||
|
||||
const hasPreviewSurface = workspaceHasPreviewSurface({
|
||||
activeTab: input.activeTab,
|
||||
projectFiles: input.projectFiles,
|
||||
liveArtifacts: input.liveArtifacts,
|
||||
streamingArtifactHtml: input.artifactHtml,
|
||||
});
|
||||
|
||||
const latestAssistant = [...input.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant');
|
||||
|
||||
if (!latestAssistant) return null;
|
||||
|
||||
const status = latestAssistant.runStatus;
|
||||
const runActive = isActiveRunStatus(status) || input.streaming;
|
||||
const runFailed = status === 'failed';
|
||||
const runStopped = status === 'canceled';
|
||||
// The agent finished its turn but is waiting on the user to answer an
|
||||
// inline question form before it can keep going.
|
||||
const awaitingInput =
|
||||
!runActive && !runFailed && !runStopped && messageHasPendingQuestion(latestAssistant);
|
||||
|
||||
let phase: GenerationPhase;
|
||||
if (runFailed) {
|
||||
phase = 'failed';
|
||||
} else if (runActive) {
|
||||
phase = 'generating';
|
||||
} else if (runStopped) {
|
||||
phase = 'stopped';
|
||||
} else if (awaitingInput) {
|
||||
phase = 'awaiting-input';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Once the user has something previewable, only the error state takes
|
||||
// over the surface; the calmer waiting states defer to the live preview
|
||||
// so we never hide a finished artifact behind a status card.
|
||||
if (hasPreviewSurface && phase !== 'failed') return null;
|
||||
|
||||
const failed = phase === 'failed';
|
||||
const events = latestAssistant.events ?? [];
|
||||
const derived = deriveGenerationPreviewModel({
|
||||
events,
|
||||
hasArtifactHtml: Boolean(input.artifactHtml?.trim()),
|
||||
hasPreviewSurface,
|
||||
failed,
|
||||
errorMessage: input.conversationError,
|
||||
});
|
||||
|
||||
const startedAt = latestAssistant.startedAt ?? latestAssistant.createdAt ?? Date.now();
|
||||
|
||||
const generating = phase === 'generating';
|
||||
const todos = generating ? latestTodosFromEvents(events) : [];
|
||||
const todoProgress =
|
||||
todos.length > 0
|
||||
? {
|
||||
done: todos.filter(
|
||||
(todo) => todo.status === 'completed' || todo.status === 'in_progress',
|
||||
).length,
|
||||
total: todos.length,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
startedAt,
|
||||
steps: derived.steps,
|
||||
phase,
|
||||
failed,
|
||||
errorMessage: derived.errorMessage,
|
||||
progressPercent: derived.progressPercent,
|
||||
activityLabel: generating ? latestActivityLabel(events) : null,
|
||||
detailLabel: generating ? generationDetailLabel(events, todos) : null,
|
||||
todoProgress,
|
||||
retryTarget: failed ? latestAssistant : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function derivePrototypeGenerationSteps(input: {
|
||||
events: AgentEvent[];
|
||||
hasArtifactHtml: boolean;
|
||||
hasPreviewSurface: boolean;
|
||||
failed: boolean;
|
||||
}): GenerationPreviewStep[] {
|
||||
const hasStatus = (labels: string[]) =>
|
||||
eventsHaveStatus(input.events, labels);
|
||||
const hasToolUse = input.events.some((event) => event.kind === 'tool_use');
|
||||
const hasWriteLikeTool = input.events.some(
|
||||
(event) =>
|
||||
event.kind === 'tool_use'
|
||||
&& typeof event.name === 'string'
|
||||
&& /^(write|edit|bash|run_terminal_cmd)$/i.test(event.name),
|
||||
);
|
||||
const hasArtifactStart = input.events.some(
|
||||
(event) => event.kind === 'text' && event.text.includes('<artifact'),
|
||||
) || input.hasArtifactHtml;
|
||||
const hasText = input.events.some((event) => event.kind === 'text' && event.text.trim().length > 0);
|
||||
|
||||
let understand: GenerationStepStatus = 'running';
|
||||
if (input.failed && !hasText && !hasToolUse) {
|
||||
understand = 'failed';
|
||||
} else if (hasText || hasStatus(['thinking', 'streaming']) || hasToolUse) {
|
||||
// `requesting`/`starting` only mean the request left the client — the
|
||||
// model hasn't produced anything yet, so we keep "understand" in
|
||||
// progress until real thinking/output/tool activity arrives. This lets
|
||||
// the UI reveal the steps one at a time instead of jumping straight to
|
||||
// a fully populated row.
|
||||
understand = 'succeeded';
|
||||
}
|
||||
|
||||
let generate: GenerationStepStatus = 'pending';
|
||||
if (understand === 'succeeded') {
|
||||
generate = 'running';
|
||||
}
|
||||
if (hasWriteLikeTool || hasArtifactStart) {
|
||||
generate = 'succeeded';
|
||||
}
|
||||
if (input.failed && understand === 'succeeded' && !hasWriteLikeTool && !hasArtifactStart) {
|
||||
generate = 'failed';
|
||||
}
|
||||
|
||||
let prepare: GenerationStepStatus = 'pending';
|
||||
if (generate === 'succeeded') {
|
||||
prepare = 'running';
|
||||
}
|
||||
if (input.hasPreviewSurface || input.hasArtifactHtml) {
|
||||
prepare = 'succeeded';
|
||||
}
|
||||
if (input.failed && generate === 'succeeded' && !input.hasPreviewSurface && !input.hasArtifactHtml) {
|
||||
prepare = 'failed';
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'understand', status: understand },
|
||||
{ id: 'generate', status: generate },
|
||||
{ id: 'prepare', status: prepare },
|
||||
];
|
||||
}
|
||||
|
||||
export function generationPreviewProgress(steps: GenerationPreviewStep[]): number {
|
||||
if (steps.length === 0) return 8;
|
||||
const weights = { pending: 0, running: 0.45, succeeded: 1, failed: 0.2 };
|
||||
const score = steps.reduce((sum, step) => sum + weights[step.status], 0) / steps.length;
|
||||
return Math.max(8, Math.min(steps.some((step) => step.status === 'failed') ? 72 : 92, Math.round(score * 100)));
|
||||
}
|
||||
|
||||
function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
|
||||
return status === 'queued' || status === 'running';
|
||||
}
|
||||
|
||||
function messageHasPendingQuestion(message: ChatMessage): boolean {
|
||||
if (typeof message.content === 'string' && QUESTION_FORM_RE.test(message.content)) {
|
||||
return true;
|
||||
}
|
||||
const events = message.events ?? [];
|
||||
return events.some((event) => event.kind === 'text' && QUESTION_FORM_RE.test(event.text));
|
||||
}
|
||||
|
||||
function latestActivityLabel(events: AgentEvent[]): string | null {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index]!;
|
||||
if (event.kind === 'thinking' && event.text.trim()) {
|
||||
return truncateActivity(event.text);
|
||||
}
|
||||
if (event.kind === 'text' && event.text.trim() && !QUESTION_FORM_RE.test(event.text)) {
|
||||
return truncateActivity(event.text);
|
||||
}
|
||||
// Intentionally skip `status` details: their payload is often an
|
||||
// internal identifier (e.g. the model slug from a `requesting` event)
|
||||
// rather than human-readable progress, so surfacing it reads as noise.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function truncateActivity(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, ' ').trim();
|
||||
return collapsed.length > 80 ? `${collapsed.slice(0, 79)}…` : collapsed;
|
||||
}
|
||||
|
||||
// The concrete operation behind the "generating" step. Prefers the agent's
|
||||
// own in-progress task label (TodoWrite `activeForm`/content), then falls
|
||||
// back to the most recent write/edit target file so the middle phase still
|
||||
// shows movement when no plan was emitted.
|
||||
function generationDetailLabel(events: AgentEvent[], todos: TodoItem[]): string | null {
|
||||
const active = todos.find((todo) => todo.status === 'in_progress');
|
||||
if (active) {
|
||||
const label = active.activeForm?.trim() || active.content.trim();
|
||||
if (label) return truncateActivity(label);
|
||||
}
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index]!;
|
||||
if (
|
||||
event.kind === 'tool_use'
|
||||
&& typeof event.name === 'string'
|
||||
&& !isTodoWriteToolName(event.name)
|
||||
&& WRITE_LIKE_TOOL_RE.test(event.name)
|
||||
) {
|
||||
const target = toolTargetName(event.input);
|
||||
if (target) return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toolTargetName(input: unknown): string | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
const raw = obj.file_path ?? obj.filePath ?? obj.path ?? obj.file;
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const segments = raw.trim().split(/[\\/]/);
|
||||
return segments[segments.length - 1] || raw.trim();
|
||||
}
|
||||
|
||||
function eventsHaveStatus(events: AgentEvent[], labels: string[]): boolean {
|
||||
const normalized = new Set(labels.map((label) => label.toLowerCase()));
|
||||
return events.some(
|
||||
(event) =>
|
||||
event.kind === 'status'
|
||||
&& normalized.has(event.label.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function failureMessageFromEvents(events: AgentEvent[]): string | null {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index]!;
|
||||
if (event.kind === 'text' && event.text.trim()) return event.text.trim();
|
||||
if (event.kind === 'status' && event.detail?.trim()) return event.detail.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -142,11 +142,35 @@ function pickEarlierMention(
|
|||
return known.token.length >= unknown.token.length ? known : unknown;
|
||||
}
|
||||
|
||||
function isMentionBoundary(text: string, start: number): boolean {
|
||||
/**
|
||||
* Left boundary rule for inline mentions: `@<token>` is a candidate
|
||||
* mention only when the character before `@` is the start of the
|
||||
* string or whitespace / opening bracket / quote. Exported so the
|
||||
* draft-side plugin-insertion tracker stays in lockstep with this
|
||||
* parser — see `apps/web/src/utils/pluginInsertionTracking.ts`.
|
||||
*/
|
||||
export function isMentionBoundary(text: string, start: number): boolean {
|
||||
if (start === 0) return true;
|
||||
return /[\s([{"']/.test(text[start - 1] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Right boundary rule for inline mentions: the parser's unknown
|
||||
* mention regex is `/@[^\s@]+/`, so a `@<token>` candidate is the
|
||||
* full mention only when the character after the token is the end
|
||||
* of the string, whitespace, or another `@` (which would start a
|
||||
* new mention). Anything else extends the parser's tokenization
|
||||
* past the candidate — e.g. `@Airbnb/foo` is parsed as a single
|
||||
* mention even when `@Airbnb` is a known plugin. Exported for the
|
||||
* same reason as `isMentionBoundary`: the draft-side tracker must
|
||||
* not declare an entry "still valid" when the parser would no
|
||||
* longer see the tracked token as a standalone mention.
|
||||
*/
|
||||
export function isMentionRightBoundary(text: string, end: number): boolean {
|
||||
if (end >= text.length) return true;
|
||||
return /[\s@]/.test(text[end] ?? '');
|
||||
}
|
||||
|
||||
function coalesceTextParts(parts: InlineMentionPart[]): InlineMentionPart[] {
|
||||
const result: InlineMentionPart[] = [];
|
||||
for (const part of parts) {
|
||||
|
|
|
|||
224
apps/web/src/utils/pluginInsertionTracking.ts
Normal file
224
apps/web/src/utils/pluginInsertionTracking.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Instance-aware tracking for `@<token>` mentions that ChatComposer
|
||||
// inserts into the draft via the @-mention popover plugin-pick path
|
||||
// (#2881, #2929). Each tracked insertion records the precise start
|
||||
// offset of `@`, so two `@Airbnb` instances in the same draft (one
|
||||
// composer-inserted, one user-typed) are individually distinguishable
|
||||
// — the chip-clear strip only removes the tracked one (#2929 round 3).
|
||||
//
|
||||
// Boundary rules are imported from `./inlineMentions` so the
|
||||
// "tracker thinks this is still a valid mention" predicate stays in
|
||||
// lockstep with the actual mention parser. Without that, drafts
|
||||
// like `@Airbnb/foo` (where the parser tokenizes the full
|
||||
// `@Airbnb/foo` as one mention) would still satisfy a permissive
|
||||
// tracker boundary, and the post-clear strip would tear out only
|
||||
// `@Airbnb`, leaving `/foo` as orphaned user-authored text
|
||||
// (#2929 round 5).
|
||||
import {
|
||||
isMentionBoundary,
|
||||
isMentionRightBoundary,
|
||||
} from './inlineMentions';
|
||||
|
||||
export type TrackedInsertion = {
|
||||
/** Bare token without the leading `@`, matching `inlineMentionToken` payload. */
|
||||
token: string;
|
||||
/** Position of `@` in the draft. The full mention occupies [start, start + token.length + 1). */
|
||||
start: number;
|
||||
/**
|
||||
* `id` of the `InstalledPluginRecord` whose `applyById` produced this
|
||||
* insertion. Used by ChatComposer to scope the post-clear strip to the
|
||||
* currently active plugin: when the user replaces plugin A with plugin
|
||||
* B (e.g. via the tools menu's `applyById` without writing to the
|
||||
* draft), the entries for A must be dropped from the tracker, otherwise
|
||||
* clearing B's chip would silently strip A's `@A` from the draft —
|
||||
* user-text deletion in a supported replace-plugin flow (#2929 round 6).
|
||||
*/
|
||||
pluginId: string;
|
||||
/**
|
||||
* Optional unique handle assigned by the producer (ChatComposer's
|
||||
* `insertPluginMention`) when the entry is pushed. Survives
|
||||
* `reconcileInsertions` so the producer's failure-path rollback can
|
||||
* locate "the entry I just pushed" even after intervening reconciles
|
||||
* shifted offsets or after `onCleared` mutated the array. Without
|
||||
* this, a `(token, pluginId)`-only match is ambiguous if the user
|
||||
* replays the same plugin pick during the await window
|
||||
* (#2929 round 10 codex review).
|
||||
*/
|
||||
insertionId?: string;
|
||||
};
|
||||
|
||||
export type EditRange = {
|
||||
/** First index where prev and next differ. */
|
||||
start: number;
|
||||
/** Index in prev one past the last differing char. */
|
||||
oldEnd: number;
|
||||
/** Index in next one past the last differing char. */
|
||||
newEnd: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Longest-common-suffix + longest-common-prefix diff of two strings.
|
||||
* Returns the minimal `[editStart, oldEnd, newEnd]` range that contains
|
||||
* every byte that differs between `prev` and `next`.
|
||||
*
|
||||
* Suffix is computed first; the prefix is then capped so the two
|
||||
* matches do not overlap. This ordering matters when the inserted
|
||||
* text shares a leading character with `prev` — e.g. prepending
|
||||
* `@github ` before `@Airbnb ` to get `@github @Airbnb `. A
|
||||
* prefix-first walk would greedily claim the leading `@` (LCP=1)
|
||||
* and then split the diff window at index 1, which crosses through
|
||||
* a tracked `@Airbnb` entry that is structurally untouched. Suffix
|
||||
* first claims the entire `@Airbnb ` from the right, leaving LCP=0
|
||||
* and a clean prepend window of `[0, 0, 8]` — the entry shifts
|
||||
* cleanly by `delta`.
|
||||
*
|
||||
* Single-point edits (typing/deleting/pasting at one location) are
|
||||
* 100% accurate. Multi-point simultaneous edits (rare) collapse into
|
||||
* one wider range, which conservatively invalidates any tracked
|
||||
* insertion overlapping that range.
|
||||
*/
|
||||
export function computeEditRange(prev: string, next: string): EditRange {
|
||||
if (prev === next) return { start: 0, oldEnd: 0, newEnd: 0 };
|
||||
const minLen = Math.min(prev.length, next.length);
|
||||
// Longest common suffix first — capped at minLen so it does not
|
||||
// walk past the start of either string.
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < minLen &&
|
||||
prev.charCodeAt(prev.length - 1 - suffix) ===
|
||||
next.charCodeAt(next.length - 1 - suffix)
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
// Longest common prefix, capped so it does not overlap the suffix.
|
||||
const maxStart = minLen - suffix;
|
||||
let start = 0;
|
||||
while (start < maxStart && prev.charCodeAt(start) === next.charCodeAt(start)) {
|
||||
start++;
|
||||
}
|
||||
return {
|
||||
start,
|
||||
oldEnd: prev.length - suffix,
|
||||
newEnd: next.length - suffix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the draft still contains `@<token>` at the given start
|
||||
* offset AND the surrounding characters make the parser see it as
|
||||
* exactly that mention (not a longer one). Boundaries delegate to
|
||||
* the same `isMentionBoundary` / `isMentionRightBoundary` helpers
|
||||
* the parser uses, so the tracker cannot diverge from the parser
|
||||
* and inadvertently strip a prefix of a longer parser-recognized
|
||||
* mention (#2929 round 5).
|
||||
*
|
||||
* Concretely: a tracked `@Airbnb` at offset 0 in `@Airbnb/foo` is
|
||||
* INVALID under this rule because the parser treats the full
|
||||
* `@Airbnb/foo` as one mention (its `@[^\s@]+` greedy regex extends
|
||||
* through `/foo`). Stripping just `@Airbnb` would leave `/foo`
|
||||
* dangling — that is user-authored text mutation, not an orphan
|
||||
* removal. Invalidating the entry on clear is the conservative
|
||||
* choice: the post-clear strip becomes a no-op, the orphan
|
||||
* styled mention remains visible, and the user can edit it
|
||||
* manually if they want.
|
||||
*/
|
||||
export function isInsertionStillValid(
|
||||
draft: string,
|
||||
start: number,
|
||||
token: string,
|
||||
): boolean {
|
||||
if (start < 0 || token.length === 0) return false;
|
||||
const tokenLen = token.length + 1; // include leading `@`
|
||||
if (start + tokenLen > draft.length) return false;
|
||||
if (draft.slice(start, start + tokenLen) !== `@${token}`) return false;
|
||||
if (!isMentionBoundary(draft, start)) return false;
|
||||
if (!isMentionRightBoundary(draft, start + tokenLen)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-map tracked insertion offsets across a draft edit. Entries
|
||||
* entirely before the edit keep their offset; entries entirely
|
||||
* after shift by `delta`; entries that overlap the edit are
|
||||
* dropped. Survivors are revalidated against the new draft so any
|
||||
* boundary corruption (e.g. user typed letters touching the right
|
||||
* edge of `@Airbnb` to form `@Airbnbify`) prunes the entry.
|
||||
*/
|
||||
export function reconcileInsertions(
|
||||
entries: ReadonlyArray<TrackedInsertion>,
|
||||
prev: string,
|
||||
next: string,
|
||||
): TrackedInsertion[] {
|
||||
if (entries.length === 0) return [];
|
||||
if (prev === next) return entries.slice();
|
||||
const { start: editStart, oldEnd, newEnd } = computeEditRange(prev, next);
|
||||
const delta = newEnd - oldEnd;
|
||||
const result: TrackedInsertion[] = [];
|
||||
for (const e of entries) {
|
||||
const tokenLen = e.token.length + 1;
|
||||
const entryEnd = e.start + tokenLen;
|
||||
let nextStart: number;
|
||||
if (entryEnd <= editStart) {
|
||||
nextStart = e.start; // entry entirely before edit
|
||||
} else if (e.start >= oldEnd) {
|
||||
nextStart = e.start + delta; // entry entirely after edit → shift
|
||||
} else {
|
||||
continue; // edit overlaps entry → drop
|
||||
}
|
||||
if (isInsertionStillValid(next, nextStart, e.token)) {
|
||||
const reconciled: TrackedInsertion = {
|
||||
token: e.token,
|
||||
start: nextStart,
|
||||
pluginId: e.pluginId,
|
||||
};
|
||||
if (e.insertionId !== undefined) reconciled.insertionId = e.insertionId;
|
||||
result.push(reconciled);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracked `@<token>` insertions from the draft by slicing each
|
||||
* entry's range. Sorts descending by start so earlier offsets stay
|
||||
* valid as later ones are excised. Invalidated entries (boundary
|
||||
* corruption since the last reconcile) are skipped — the safe failure
|
||||
* mode is under-delete, never over-delete.
|
||||
*
|
||||
* Whitespace handling (#2929 round 8): when both the character before
|
||||
* `@` and the character after the token are whitespace, the slice
|
||||
* removes one of them in addition to the token to avoid leaving
|
||||
* doubled whitespace at the seam (e.g. `text @Airbnb more` → `text
|
||||
* more`, not `text more`). Whitespace ELSEWHERE in the draft is
|
||||
* never touched — a previous version of this function ran a global
|
||||
* `[ \t]{2,}` collapse over the entire result, which silently
|
||||
* rewrote any user-authored multi-space spans (e.g. `keep gap` →
|
||||
* `keep gap`). Round 8 reviewer flagged that as prompt corruption
|
||||
* in the changed flow.
|
||||
*/
|
||||
export function stripPluginInsertedTokens(
|
||||
draft: string,
|
||||
entries: ReadonlyArray<TrackedInsertion>,
|
||||
): string {
|
||||
if (!draft || entries.length === 0) return draft;
|
||||
const valid = entries
|
||||
.filter((e) => isInsertionStillValid(draft, e.start, e.token))
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let next = draft;
|
||||
for (const e of valid) {
|
||||
const tokenLen = e.token.length + 1;
|
||||
const leftIdx = e.start - 1;
|
||||
const rightIdx = e.start + tokenLen;
|
||||
const leftIsWs =
|
||||
leftIdx >= 0 && /[ \t]/.test(next[leftIdx] ?? '');
|
||||
const rightIsWs =
|
||||
rightIdx < next.length && /[ \t]/.test(next[rightIdx] ?? '');
|
||||
// Seam-local collapse only: if both sides are whitespace,
|
||||
// extend the slice by one character so the seam ends up with
|
||||
// a single space instead of two. Anything outside this range
|
||||
// — including user-authored multi-space spans elsewhere — is
|
||||
// left untouched.
|
||||
const sliceEnd = leftIsWs && rightIsWs ? rightIdx + 1 : rightIdx;
|
||||
next = next.slice(0, e.start) + next.slice(sliceEnd);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
311
apps/web/tests/runtime/generation-preview.test.ts
Normal file
311
apps/web/tests/runtime/generation-preview.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildGenerationPreviewState,
|
||||
derivePrototypeGenerationSteps,
|
||||
workspaceHasPreviewSurface,
|
||||
} from '../../src/runtime/generation-preview';
|
||||
import type { AgentEvent, ChatMessage } from '../../src/types';
|
||||
|
||||
describe('generation preview helpers', () => {
|
||||
it('detects when the workspace already has a preview surface', () => {
|
||||
expect(
|
||||
workspaceHasPreviewSurface({
|
||||
activeTab: 'index.html',
|
||||
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
|
||||
liveArtifacts: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
workspaceHasPreviewSurface({
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
streamingArtifactHtml: '<html><body>hi</body></html>',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('advances the three prototype steps from streamed events', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'status', label: 'thinking' },
|
||||
{ kind: 'text', text: 'Planning the page.' },
|
||||
{ kind: 'tool_use', id: '1', name: 'Write', input: { file_path: 'index.html' } },
|
||||
];
|
||||
expect(
|
||||
derivePrototypeGenerationSteps({
|
||||
events,
|
||||
hasArtifactHtml: false,
|
||||
hasPreviewSurface: false,
|
||||
failed: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: 'understand', status: 'succeeded' },
|
||||
{ id: 'generate', status: 'succeeded' },
|
||||
{ id: 'prepare', status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the understand step in progress while the request is still pending', () => {
|
||||
// `requesting` only means the request left the client; nothing should
|
||||
// advance past the first step until real model activity arrives, so the
|
||||
// UI can reveal steps one at a time.
|
||||
expect(
|
||||
derivePrototypeGenerationSteps({
|
||||
events: [{ kind: 'status', label: 'requesting', detail: 'claude-opus-4-7' }],
|
||||
hasArtifactHtml: false,
|
||||
hasPreviewSurface: false,
|
||||
failed: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: 'understand', status: 'running' },
|
||||
{ id: 'generate', status: 'pending' },
|
||||
{ id: 'prepare', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds preview state for an active assistant run without an open preview tab', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now() - 5_000,
|
||||
events: [{ kind: 'status', label: 'thinking' }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [{ id: 'u1', role: 'user', content: 'Build a landing page' }, assistant],
|
||||
streaming: true,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.phase).toBe('generating');
|
||||
// A `thinking` status is enough evidence that the model started, so the
|
||||
// first step has already advanced past "running".
|
||||
expect(state?.steps[0]?.status).toBe('succeeded');
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the latest activity snippet while generating', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now(),
|
||||
events: [
|
||||
{ kind: 'status', label: 'thinking' },
|
||||
{ kind: 'thinking', text: 'Sketching the hero section layout' },
|
||||
],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: true,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.activityLabel).toBe('Sketching the hero section layout');
|
||||
});
|
||||
|
||||
it('derives a concrete sub-status and task count while generating', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now(),
|
||||
events: [
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 't1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Plan layout', status: 'completed' },
|
||||
{ content: 'Write index.html', activeForm: 'Writing index.html', status: 'in_progress' },
|
||||
{ content: 'Self-check', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: true,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.detailLabel).toBe('Writing index.html');
|
||||
// The in-progress task counts toward `done`, matching the chat todo card.
|
||||
expect(state?.todoProgress).toEqual({ done: 2, total: 3 });
|
||||
});
|
||||
|
||||
it('falls back to the latest write target when no plan is present', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now(),
|
||||
events: [
|
||||
{ kind: 'text', text: 'Writing the page now.' },
|
||||
{ kind: 'tool_use', id: 't1', name: 'Write', input: { file_path: 'src/index.html' } },
|
||||
],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: true,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.detailLabel).toBe('index.html');
|
||||
expect(state?.todoProgress).toBeNull();
|
||||
});
|
||||
|
||||
it('omits sub-status data once the run is no longer generating', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'Partial work',
|
||||
runStatus: 'canceled',
|
||||
startedAt: Date.now(),
|
||||
events: [
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 't1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Write index.html', status: 'in_progress' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.phase).toBe('stopped');
|
||||
expect(state?.detailLabel).toBeNull();
|
||||
expect(state?.todoProgress).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a paused surface when the run was stopped without a preview', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'Partial work',
|
||||
runStatus: 'canceled',
|
||||
startedAt: Date.now() - 10_000,
|
||||
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.phase).toBe('stopped');
|
||||
expect(state?.failed).toBe(false);
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a waiting surface when the agent is asking the user a question', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'A few quick questions:\n<question-form id="discovery" title="Brief">{"questions":[]}</question-form>',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: Date.now() - 4_000,
|
||||
events: [{ kind: 'text', text: '<question-form id="discovery">{"questions":[]}</question-form>' }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.phase).toBe('awaiting-input');
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a finished run that produced no question or preview', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'All done!',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: Date.now() - 4_000,
|
||||
events: [{ kind: 'text', text: 'All done!' }],
|
||||
};
|
||||
expect(
|
||||
buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a failed state with a retry target', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'failed',
|
||||
startedAt: Date.now() - 8_000,
|
||||
events: [{ kind: 'text', text: 'Model request failed' }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
conversationError: 'Network error',
|
||||
});
|
||||
expect(state?.phase).toBe('failed');
|
||||
expect(state?.failed).toBe(true);
|
||||
expect(state?.errorMessage).toBe('Network error');
|
||||
expect(state?.retryTarget).toBe(assistant);
|
||||
});
|
||||
|
||||
it('hides preview state once a preview tab is active', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now(),
|
||||
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
|
||||
};
|
||||
expect(
|
||||
buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: true,
|
||||
activeTab: 'index.html',
|
||||
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
|
||||
liveArtifacts: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
310
apps/web/tests/utils/pluginInsertionTracking.test.ts
Normal file
310
apps/web/tests/utils/pluginInsertionTracking.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeEditRange,
|
||||
isInsertionStillValid,
|
||||
reconcileInsertions,
|
||||
stripPluginInsertedTokens,
|
||||
type TrackedInsertion,
|
||||
} from '../../src/utils/pluginInsertionTracking';
|
||||
|
||||
// Pure-function coverage for the diff/reconcile/strip primitives that
|
||||
// back ChatComposer's instance-aware plugin mention tracking
|
||||
// (#2929 round 3). The integration spec
|
||||
// (`ChatComposer.plugin-clear-prunes-draft.test.tsx`) exercises the
|
||||
// end-to-end React path; this file pins the edge cases the integration
|
||||
// flow is unlikely to hit, so a regression in the math surfaces here
|
||||
// before it can corrupt user drafts.
|
||||
|
||||
describe('computeEditRange', () => {
|
||||
it('returns an empty range when the strings are equal', () => {
|
||||
expect(computeEditRange('abc', 'abc')).toEqual({ start: 0, oldEnd: 0, newEnd: 0 });
|
||||
});
|
||||
|
||||
it('detects a pure prefix append', () => {
|
||||
// `prev` is the suffix of `next`; the diff sits at the very start.
|
||||
expect(computeEditRange('world', 'hello world')).toEqual({
|
||||
start: 0,
|
||||
oldEnd: 0,
|
||||
newEnd: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a pure suffix append', () => {
|
||||
expect(computeEditRange('hello', 'hello world')).toEqual({
|
||||
start: 5,
|
||||
oldEnd: 5,
|
||||
newEnd: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a middle replacement', () => {
|
||||
expect(computeEditRange('abc XYZ def', 'abc 12345 def')).toEqual({
|
||||
start: 4,
|
||||
oldEnd: 7,
|
||||
newEnd: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a full deletion to empty', () => {
|
||||
expect(computeEditRange('hello', '')).toEqual({ start: 0, oldEnd: 5, newEnd: 0 });
|
||||
});
|
||||
|
||||
it('does not let prefix and suffix overlap when one string is a substring of the other', () => {
|
||||
// Both strings share the leading `aa`. If suffix matching greedily
|
||||
// walked past `start`, the range could go negative.
|
||||
const r = computeEditRange('aa', 'aaa');
|
||||
expect(r.start).toBeLessThanOrEqual(r.oldEnd);
|
||||
expect(r.start).toBeLessThanOrEqual(r.newEnd);
|
||||
});
|
||||
|
||||
it('treats prepended text that shares a leading char with prev as a clean prepend (#2929 round 4)', () => {
|
||||
// Inserting `@github ` before `@Airbnb ` gives `@github @Airbnb `.
|
||||
// A naive LCP-first algorithm matches the leading `@`, then walks
|
||||
// LCS backwards through `Airbnb `, and reports the edit as
|
||||
// `editStart=1, oldEnd=1, newEnd=9`. That window cuts through a
|
||||
// tracked entry at offset 0 even though `@Airbnb` was not
|
||||
// structurally touched. LCS-first is required so the entire
|
||||
// `@Airbnb ` suffix is claimed by the right side and the diff
|
||||
// collapses to a clean prepend of `[0, 0, 8]`.
|
||||
const r = computeEditRange('@Airbnb ', '@github @Airbnb ');
|
||||
expect(r).toEqual({ start: 0, oldEnd: 0, newEnd: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsertionStillValid', () => {
|
||||
it('accepts a token at the start of the draft', () => {
|
||||
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token after a whitespace boundary', () => {
|
||||
expect(isInsertionStillValid('see @Airbnb', 4, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when the surrounding letter forms a longer mention', () => {
|
||||
// `@Airbnbx` would render as a single mention so the tracked range
|
||||
// is no longer the intended target.
|
||||
expect(isInsertionStillValid('@Airbnbx', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the left boundary is a non-mention character', () => {
|
||||
// `x@Airbnb` is not a valid mention per inlineMentions boundary
|
||||
// rules — the `x` immediately to the left is a word char.
|
||||
expect(isInsertionStillValid('x@Airbnb', 1, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the offset no longer points at the token', () => {
|
||||
expect(isInsertionStillValid('compare @Airbnb', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative or out-of-range offsets', () => {
|
||||
expect(isInsertionStillValid('@Airbnb', -1, 'Airbnb')).toBe(false);
|
||||
expect(isInsertionStillValid('@Airbnb', 100, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
// Parser-alignment cases (#2929 round 5): the inline-mention parser
|
||||
// tokenizes `@<token>` greedily through `[^\s@]`, then prefers the
|
||||
// longer match at the same start offset. A tracked entry must
|
||||
// therefore be invalidated whenever the right-boundary character
|
||||
// would extend the parser's mention past the tracked range — that
|
||||
// is, anything other than EOS, whitespace, or another `@`. Without
|
||||
// these rejections the post-clear strip would carve `@Airbnb` out
|
||||
// of `@Airbnb/foo`, leaving `/foo` dangling as user-authored text
|
||||
// mutation.
|
||||
it('rejects when followed by `/` (parser would tokenize a longer mention)', () => {
|
||||
expect(isInsertionStillValid('@Airbnb/foo', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `.`', () => {
|
||||
expect(isInsertionStillValid('@Airbnb.test', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `,`', () => {
|
||||
expect(isInsertionStillValid('@Airbnb,', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `)` (parser would extend through the paren)', () => {
|
||||
expect(isInsertionStillValid('see (@Airbnb), then ship', 5, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts when followed by another `@` (next mention starts there)', () => {
|
||||
expect(isInsertionStillValid('@Airbnb@other', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts at end-of-string', () => {
|
||||
expect(isInsertionStillValid('@Airbnb', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts when followed by whitespace', () => {
|
||||
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileInsertions', () => {
|
||||
const entry: TrackedInsertion = { token: 'Airbnb', start: 0, pluginId: 'airbnb' };
|
||||
|
||||
it('returns a fresh copy when nothing changed', () => {
|
||||
const out = reconcileInsertions([entry], '@Airbnb ', '@Airbnb ');
|
||||
expect(out).toEqual([entry]);
|
||||
expect(out).not.toBe([entry]); // new array
|
||||
});
|
||||
|
||||
it('keeps an entry whose tail sits before the edit', () => {
|
||||
// `@Airbnb` at [0,7], edit happens at index 8 (typing after the trailing space)
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb ',
|
||||
'@Airbnb compare',
|
||||
);
|
||||
expect(next).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('shifts an entry whose head sits after the edit', () => {
|
||||
// Insert `prefix ` (7 chars) at the beginning. Entry start moves 0 → 7.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb ',
|
||||
'prefix @Airbnb ',
|
||||
);
|
||||
expect(next).toEqual([{ token: 'Airbnb', start: 7, pluginId: 'airbnb' }]);
|
||||
});
|
||||
|
||||
it('drops an entry the edit overlaps', () => {
|
||||
// User selects through the entry and replaces it with other text.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb plan',
|
||||
'@Air-other plan',
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an entry whose right boundary is corrupted (letters touching)', () => {
|
||||
// Typing `ify` immediately after `@Airbnb` makes it `@Airbnbify`
|
||||
// which is no longer a valid mention.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb',
|
||||
'@Airbnbify',
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple entries with mixed shift / keep / drop outcomes', () => {
|
||||
// prev: `@A xxx @B yyy`
|
||||
// entry1 at 0 entry2 at 11
|
||||
// edit: replace `xxx` (cols 4-6) with `12345` (delta = +2)
|
||||
const e1: TrackedInsertion = { token: 'A', start: 0, pluginId: 'a' };
|
||||
const e2: TrackedInsertion = { token: 'B', start: 11, pluginId: 'b' };
|
||||
const prev = '@A xxx @B yyy';
|
||||
const next = '@A 12345 @B yyy';
|
||||
const out = reconcileInsertions([e1, e2], prev, next);
|
||||
expect(out).toEqual([
|
||||
{ token: 'A', start: 0, pluginId: 'a' },
|
||||
{ token: 'B', start: 13, pluginId: 'b' }, // 11 + 2
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the entries are empty', () => {
|
||||
expect(reconcileInsertions([], 'a', 'b')).toEqual([]);
|
||||
});
|
||||
|
||||
// Purity guard (#2929 round 5): reconcile must not mutate its
|
||||
// inputs and must produce the same output regardless of how many
|
||||
// times it is called with the same arguments. React StrictMode
|
||||
// double-invokes setState updaters in development; the previous
|
||||
// implementation called reconcile *inside* the updater and
|
||||
// accumulated shifts (entry at 0 → 8 → 16) on the second
|
||||
// invocation, dropping the entry as out-of-range. The fix moves
|
||||
// reconcile out of the updater, but pinning purity here too so a
|
||||
// future regression there is caught at the algorithm layer.
|
||||
it('is pure: invoking twice with the same args returns equivalent output and does not mutate input', () => {
|
||||
const entries: TrackedInsertion[] = [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }];
|
||||
const frozen = Object.freeze([...entries]) as ReadonlyArray<TrackedInsertion>;
|
||||
const first = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
|
||||
const second = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
|
||||
expect(first).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
|
||||
expect(second).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
|
||||
// Frozen input was not mutated (any attempt would have thrown
|
||||
// in strict mode).
|
||||
expect(frozen).toEqual([{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPluginInsertedTokens', () => {
|
||||
it('returns the draft unchanged when there are no entries', () => {
|
||||
expect(stripPluginInsertedTokens('@Airbnb ', [])).toBe('@Airbnb ');
|
||||
});
|
||||
|
||||
it('removes a single tracked token at the start of the draft', () => {
|
||||
expect(
|
||||
stripPluginInsertedTokens('@Airbnb ', [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]),
|
||||
).toBe(' '); // trailing space from inserted text remains; integration trims as needed
|
||||
});
|
||||
|
||||
it('removes a tracked token while preserving an untracked duplicate (#2929 round 3)', () => {
|
||||
// The whole point: composer-inserted `@Airbnb` at offset 0 gets
|
||||
// removed; the user-authored `@Airbnb` at offset 16 is untracked
|
||||
// and therefore preserved.
|
||||
const draft = '@Airbnb compare @Airbnb with our spec';
|
||||
const out = stripPluginInsertedTokens(draft, [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
|
||||
expect(out).toBe(' compare @Airbnb with our spec');
|
||||
});
|
||||
|
||||
it('slices multiple tracked tokens in one pass without offset drift', () => {
|
||||
// Two tracked entries, descending sort means the right one is
|
||||
// sliced first so the left one's offset stays valid.
|
||||
const draft = '@A and @B';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'A', start: 0, pluginId: 'a' },
|
||||
{ token: 'B', start: 7, pluginId: 'b' },
|
||||
]);
|
||||
expect(out).toBe(' and ');
|
||||
});
|
||||
|
||||
it('drops invalidated entries instead of corrupting unrelated text', () => {
|
||||
// The tracked offset no longer points at `@Airbnb` (user retyped).
|
||||
// strip should be a no-op rather than deleting whatever sits at the
|
||||
// stale offset.
|
||||
const draft = 'hello world';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 0, pluginId: 'airbnb' },
|
||||
]);
|
||||
expect(out).toBe('hello world');
|
||||
});
|
||||
|
||||
it('collapses double whitespace left behind by the strip', () => {
|
||||
const draft = 'see @Airbnb here';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 4, pluginId: 'airbnb' },
|
||||
]);
|
||||
// After slicing `@Airbnb`: `see here` (two spaces) → collapse to `see here`
|
||||
expect(out).toBe('see here');
|
||||
});
|
||||
|
||||
it('does not normalize user-authored multi-space spans elsewhere in the draft (#2929 round 8)', () => {
|
||||
// Reviewer-flagged: the previous global `[ \t]{2,}` collapse
|
||||
// would rewrite any user-authored double-space span to a
|
||||
// single space, even ones unrelated to the strip seam. The
|
||||
// seam-local collapse here only touches the whitespace
|
||||
// adjacent to the removed range.
|
||||
const draft = 'keep gap @Airbnb here';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 10, pluginId: 'airbnb' },
|
||||
]);
|
||||
// `keep gap` (two spaces) is preserved; the `@Airbnb` seam
|
||||
// collapses to a single space.
|
||||
expect(out).toBe('keep gap here');
|
||||
});
|
||||
|
||||
it('preserves multi-space spans on both sides of an unrelated mention (#2929 round 8)', () => {
|
||||
// Two user-authored double-space spans flank an `@Airbnb`
|
||||
// that is not tracked. Strip should be a no-op (no entries
|
||||
// for it) — verifies that nothing in the function reaches
|
||||
// out and normalizes whitespace when it has no entries to
|
||||
// operate on.
|
||||
const draft = 'one two @Untracked three four';
|
||||
const out = stripPluginInsertedTokens(draft, []);
|
||||
expect(out).toBe('one two @Untracked three four');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue